Skip to main content

ARCHITECTURE

Architecture

Why This Exists

This repository uses a hexagonal (ports/adapters) architecture to keep domain logic isolated, testable, and reusable across API, bot, and worker.

Ports vs Adapters (Infrastructure)

Rule of thumb: Ports describe what the app needs; adapters describe how it’s done.

LayerRoleMay importMust NOT
Types (src/types)Shared contracts only; pure types, no runtime logic, no side effects.Nothing outside src/types.Ports, adapters, app code, utils.
Ports (src/ports)Define app-level capabilities and interfaces; own lifecycle, config, context wiring.Types, adapters, utils.Third-party libraries directly.
Adapters (src/adapters)Concrete implementations of port interfaces; wrap third-party systems (logging, DB, telemetry).Types only.Ports, bootstrap, app code, utils.
App code (src/data, src/endpoints)Domain, services, endpoints.Ports, types.Adapters directly (wire adapters in bootstrap).
Bootstrap (src/bootstrap)Wires adapters into ports (e.g. createAppContextPort({ logger: createPinoAdapter(), db: createMongoAdapter() })).Ports, adapters, types.
  • Ports are stable and app-facing; they MUST NOT directly depend on external libraries. They MAY select and compose adapters internally (or receive them via config from bootstrap).
  • Adapters are replaceable infrastructure (e.g. Pino, Sentry, Mongo); all adapter files are named *.adapter.ts. Import via @adapters or @adapters/*.
  • App code must never import adapters directly; use ports. Only bootstrap (or equivalent wiring) should import adapters and pass them into ports.

Layering Rules (Hard Constraints)

  • src/data/*/*.repository.ts defines ports only (no framework imports).
  • src/data/*/*.repository.mongoose.ts are adapters (mongoose allowed).
  • src/data/*/*.endpoint.ts are inbound adapters (express only).
  • src/data/*/*.service.ts contains application logic (ports + validators only).
  • src/data/*/*.model.ts contains mongoose schemas/models only.
  • src/data/*/*.validators.ts contains Zod schemas and DTO validation only.
  • No direct DB access outside repository adapters.
  • No cross-layer imports from adapters into ports or services.

What Must Never Happen

  • Business logic inside express handlers.
  • Mongoose imports in ports, services, or validators.
  • Cross-app imports (apps/* importing other apps).
  • Public contract changes without updating tests.

Where Complexity Is Allowed

  • Repositories: query optimization, persistence edge-cases.
  • Services: orchestration, invariants, workflows.
  • Adapters: mapping and validation only.

Shared Business Logic Across Entrypoints

Put reusable business behavior in services under src/data/<feature>/. Keep that logic independent of transport/framework so API, bot, jobs, and events can call the same code path.

Where Shared Logic Lives

  • Feature rules/invariants: src/data/<feature>/<feature>.service.ts
  • Feature input/output contracts: src/data/<feature>/<feature>.validators.ts and src/types/* (for cross-feature contracts)
  • Persistence contracts (ports): src/data/<feature>/<feature>.repository.ts
  • Persistence implementation details: *.repository.mongoose.ts only

If logic is used by multiple features, create a dedicated domain feature module (for example src/data/billing/) and expose service functions there instead of placing shared business rules in utils, endpoints, or adapters.

How Entrypoints Reuse It (API/Bot/Worker/Admin)

  • Endpoints/commands/jobs/events stay thin: validate/map → call service.
  • Entrypoint modules (*.endpoint.ts, *.command.ts, *.events.ts, *.jobs.ts) may orchestrate request metadata, but not business decisions.
  • Business decisions and invariants stay in services so all entrypoints behave consistently.

Web App Usage (Client-Side)

  • Browser apps should not import src/data/*, ports, or adapters.
  • Web apps consume API endpoints and share contracts from src/types exports that are intentionally public.
  • If business behavior must run in both backend and frontend, extract pure, framework-free rules into a dedicated shared module and keep IO/orchestration in backend services.

Non-Negotiable Boundaries for Shared Logic

  • No business logic in src/endpoints/* or src/data/*/*.endpoint.ts.
  • No framework/DB imports in services.
  • No adapter imports from app code (src/data, src/endpoints).
  • Ports expose capabilities; they are not a home for domain rules.

Import Direction

Adapters -> Services -> Ports Models and validators are leaf dependencies.

Init and Server Lifecycle

The API startup path is intentionally split into two files:

  • src/init/api.init.ts is the API composition root for runtime wiring.
  • src/server.ts is a thin process launcher only (initApi() + start() + signal teardown).

src/init/api.init.ts Responsibilities

  • Build projectConfig from environment/cwd.
  • Compose adapters/ports and create appContext.
  • Build BootstrapDeps once and pass it into bootstrappers.
  • Create Express middleware/router wiring (createDataRouter, mountAdmin, health/root routes).
  • Expose lifecycle methods (start(), stop()) and manage HTTP server + DB connect/disconnect.
  • Seed auth provider records needed for local/Discord auth defaults.

src/server.ts Responsibilities

  • Call initApi({ cwd: process.cwd() }).
  • Start the initialized API once (await api.start()).
  • Handle SIGINT/SIGTERM and delegate shutdown to api.stop().
  • Avoid composition logic, adapter selection, route wiring, or DB setup.

Import Boundaries for Init and Server

  • src/server.ts should import init only (and process/runtime primitives).
  • src/init/api.init.ts may import bootstrappers, ports, adapters, and model/bootstrap wiring needed for composition.
  • Feature modules must not import src/init/* or src/server.ts.
  • Entrypoints should continue to flow via registries, not direct feature imports from launch code.

Import and Composition Flow

The flow has two lanes:

  • Composition-time wiring: init composes ports with concrete adapters.
  • Runtime usage: app/feature code consumes ports (not adapters).

Code Quality Conventions

These conventions are enforced by project rules and should be treated as default engineering expectations for src/**/*.ts changes.

  • Documentation: Add JSDoc for exported/shared classes, functions, and methods.
  • Inline clarity: Add explicit inline comments for non-obvious branching and control flow.
  • Testing baseline: New or changed behavior must include at least unit tests.
  • Test location: Co-locate tests as *.spec.ts / *.integration.spec.ts next to source.
  • DRY: Extract repeated logic into reusable helpers (typically src/utils/*) when patterns repeat.
  • Single responsibility: Keep functions/classes/modules focused; split units with multiple unrelated reasons to change.

Bot Readiness Implementation Notes

The LED-74 bot readiness slice follows the same architecture boundaries:

  1. Ingress and transport handling live in src/endpoints/discord.endpoint.ts (slash commands, MESSAGE_COMPONENT, MODAL_SUBMIT).
  2. Command routing lives in discord-command-router.ts (execute, executeComponent, executeModal) and order.command.ts; rich responses use discord-embed.types.ts and discord-custom-id.constants.ts.
  3. Orchestration lives in services (order-flow, order-session, payment, inventory, bot-config, alert).
  4. Replay safety uses durable persisted interaction keys and cache-backed short-lived locks.
  5. Valkey is used for cache/coordination; Mongo remains source of truth.

Orchestration Composition Pattern

For complex workflows (for example Discord ordering), use this pattern:

  1. *.service.ts contains business orchestration and accepts dependencies via createXWithDeps(...).
  2. *.factory.ts assembles repository/service graphs once at wiring boundaries.
  3. Endpoints/commands resolve a fully-built service from the factory and then only dispatch calls.

This keeps request paths thin and avoids fragile nested service construction in handlers.

Error and Constant Discoverability

  • Service-emitted errors belong in feature-local *.errors.ts files.
  • Domain strings (statuses, commands, event names, sources, etc.) are hoisted into *.types.ts/*.constants.ts as as const objects and reused across validators/models/services/tests.

Structure Signals (Make the Right Thing Easy)

src/
types/ # Shared contracts only (*.types.ts)
ports/ # App capabilities & context (no direct third-party deps)
adapters/ # Concrete implementations (*.adapter.ts); Pino, Sentry, Mongo, etc.
init/ # Runtime composition root(s), e.g. api.init.ts
server.ts # Thin launcher (init + lifecycle only)
bootstrap/ # Wires adapters into ports
data/
<feature>/
<feature>.model.ts
<feature>.validators.ts
<feature>.repository.ts
<feature>.repository.mongoose.ts
<feature>.service.ts
<feature>.endpoint.ts

If a change does not match this layout, reconsider the change.

Module Registries Per Entrypoint Type

This repo supports multiple entrypoints (API, bot/commands, workers/jobs, event subscribers). To keep bootstrapping clean and prevent “spiderweb imports,” we use module registries per entrypoint type.

Why Registries

Entrypoints should not import feature modules directly.

Adding a new feature should be “create the module files” (convention), not “touch 5 unrelated entrypoints.”

Registries centralize discovery/wiring while keeping features independent and reusable.

Registry Rules

Each entrypoint type has exactly one registry responsible for wiring all modules for that entrypoint.

Bootstraps import registries; registries import feature entrypoint modules.

Feature modules must never import registries or bootstrap code.

Registry Locations

src/registries/
admin.registry.ts # AdminJS resources (bootstrap → registry → *.admin.ts)
endpoints.registry.ts # Express endpoints
commands.registry.ts # Discord/CLI commands (if applicable)
jobs.registry.ts # Queue job handlers
events.registry.ts # Event subscriptions/handlers
tasks.registry.ts # Cron/maintenance tasks (optional)

Feature Module Contract (Conventions)

Each feature module that participates in an entrypoint must export a single register* function from a conventionally named file:

  • src/data/<feature>/<feature>.admin.tsregisterAdminResources(...) (AdminJS resources; config-only)
  • src/data/<feature>/<feature>.endpoint.tsregisterEndpoints(...)
  • src/data/<feature>/<feature>.command.tsregisterCommands(...)
  • src/data/<feature>/<feature>.jobs.ts (or *.queue.ts) → registerJobHandlers(...)
  • src/data/<feature>/<feature>.events.tsregisterEventHandlers(...)
  • src/data/<feature>/<feature>.tasks.tsregisterTasks(...) (optional)

Note: Entrypoints must call only registries—never feature modules directly.

Import Direction for Registries

bootstrap → registries → data/<feature>/(admin|endpoint|command|jobs|events|tasks)

Prohibited Import Patterns

  • src/bootstrap/_ must not import src/data/<feature>/_ directly
  • src/data/<feature>/_ must not import src/registries/_
  • Feature modules must not reach across to other feature modules directly (use events/ports instead)

No Import Cycles

Rule: No circular dependencies under src/.

Why it matters: Cycles can cause partial initialization and undefined imports at runtime. The dependency graph must be acyclic so that layers (types → adapters → ports → app code → registries → bootstrap) load in a well-defined order.

Enforcement:

  • CI runs pnpm check:cycles (Madge) as a hard gate. The build fails if any cycle is detected.
  • Run locally: pnpm check:cycles before pushing.

Barrels: index.ts barrels should be static re-exports only. Prefer importing from leaf modules (e.g. @data/feature/feature.service.js) rather than from barrels when doing so avoids a cycle. Do not let a module import its own barrel.


Adapters Registry & Environment Profiles

Infrastructure is composed in a single adapter registry, selected by environment profile:

File: src/bootstrap/adapters.registry.ts

Purpose

  • Ports define what the app needs.
  • Adapters define how those needs are fulfilled.
  • Switching infrastructure (e.g., log/db/queue) should be a wiring change, not an app/port code change.

Supported Profiles

  • dev: mongo + pino (pretty) + sentry disabled
  • prod: mongo + pino (json) + sentry enabled
  • test: in-memory/noop versions

Rules

  • Only bootstrap selects profiles and creates adapters.
  • Ports receive adapters via config/injection.
  • Never branch in app code based on infrastructure environment—do this only in bootstrap.

Expected Registry Shape

The adapters.registry.ts should return an object with concrete implementations for infrastructure concerns, such as:

  • logger
  • telemetry
  • db
  • cache
  • queueManager (or jobQueue)
  • clock
  • eventBus
  • (etc. as needed)

Testing Discipline

  • The test profile should always use noop or in-memory adapters by default.
  • Integration tests may override adapters (e.g., use real mongo) without altering prod/development wiring.

Updated Structure Signals

src/
types/
ports/
adapters/
init/ # Runtime composition roots (api.init.ts, worker.init.ts, etc.)
server.ts # Thin launcher for API process lifecycle
registries/ # Module registries per entrypoint type
bootstrap/
adapters.registry.ts # Environment profile selection + adapter construction
admin.bootstrap.ts # Mounts AdminJS via admin registry
express.bootstrap.ts # Bootstraps express + mounts endpoint registry
worker.bootstrap.ts # Bootstraps workers + mounts jobs registry
bot.bootstrap.ts # Bootstraps bot + mounts commands registry (if applicable)
data/
<feature>/
<feature>.admin.ts # exports registerAdminResources(...) (AdminJS; config-only)
<feature>.endpoint.ts # exports registerEndpoints(...)
<feature>.command.ts # exports registerCommands(...)
<feature>.jobs.ts # exports registerJobHandlers(...)
<feature>.events.ts # exports registerEventHandlers(...)
...

Reconsider your change if it does not match this layout.