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.
| Layer | Role | May import | Must 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@adaptersor@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.tsdefines ports only (no framework imports).src/data/*/*.repository.mongoose.tsare adapters (mongoose allowed).src/data/*/*.endpoint.tsare inbound adapters (express only).src/data/*/*.service.tscontains application logic (ports + validators only).src/data/*/*.model.tscontains mongoose schemas/models only.src/data/*/*.validators.tscontains 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.tsandsrc/types/*(for cross-feature contracts) - Persistence contracts (ports):
src/data/<feature>/<feature>.repository.ts - Persistence implementation details:
*.repository.mongoose.tsonly
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/typesexports 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/*orsrc/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.tsis the API composition root for runtime wiring.src/server.tsis a thin process launcher only (initApi()+start()+ signal teardown).
src/init/api.init.ts Responsibilities
- Build
projectConfigfrom environment/cwd. - Compose adapters/ports and create
appContext. - Build
BootstrapDepsonce 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/SIGTERMand delegate shutdown toapi.stop(). - Avoid composition logic, adapter selection, route wiring, or DB setup.
Import Boundaries for Init and Server
src/server.tsshould import init only (and process/runtime primitives).src/init/api.init.tsmay import bootstrappers, ports, adapters, and model/bootstrap wiring needed for composition.- Feature modules must not import
src/init/*orsrc/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:
initcomposes 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.tsnext 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:
- Ingress and transport handling live in
src/endpoints/discord.endpoint.ts(slash commands, MESSAGE_COMPONENT, MODAL_SUBMIT). - Command routing lives in
discord-command-router.ts(execute,executeComponent,executeModal) andorder.command.ts; rich responses usediscord-embed.types.tsanddiscord-custom-id.constants.ts. - Orchestration lives in services (
order-flow,order-session,payment,inventory,bot-config,alert). - Replay safety uses durable persisted interaction keys and cache-backed short-lived locks.
- Valkey is used for cache/coordination; Mongo remains source of truth.
Orchestration Composition Pattern
For complex workflows (for example Discord ordering), use this pattern:
*.service.tscontains business orchestration and accepts dependencies viacreateXWithDeps(...).*.factory.tsassembles repository/service graphs once at wiring boundaries.- 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.tsfiles. - Domain strings (statuses, commands, event names, sources, etc.) are hoisted
into
*.types.ts/*.constants.tsasas constobjects 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.ts→registerAdminResources(...)(AdminJS resources; config-only)src/data/<feature>/<feature>.endpoint.ts→registerEndpoints(...)src/data/<feature>/<feature>.command.ts→registerCommands(...)src/data/<feature>/<feature>.jobs.ts(or*.queue.ts) →registerJobHandlers(...)src/data/<feature>/<feature>.events.ts→registerEventHandlers(...)src/data/<feature>/<feature>.tasks.ts→registerTasks(...)(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 importsrc/data/<feature>/_directlysrc/data/<feature>/_must not importsrc/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:cyclesbefore 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
bootstrapselects 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.