Base Architecture Idea
This project uses a feature-first modular architecture in apps/api/src/modules/* with clear boundaries and one consistent implementation style across modules:
- adapters (HTTP, persistence, events) at the outside
- application (use-cases + ports + module DTOs) in the middle
- domain (entities, invariants, events, errors, value objects) at the core
The goal is to keep business behavior stable even when transport (HTTP), storage (Prisma/DB), or integration channels (SQS/events) evolve, while keeping developer workflow predictable from module to module.
1) Rich model: what it means here
The model is considered "rich" because business behavior is explicit in domain/application, not hidden in adapters.
In this codebase that currently means:
- domain entities/aggregates encapsulate state transitions
- domain invariants live in
domain/invariants/* - domain errors live in
domain/errors/* - domain events are explicit typed unions in
domain/events/* - use-cases rehydrate entity state, call entity behavior, persist, and publish pulled events
It does not mean heavyweight OOP entities everywhere. We currently use function-first entities/aggregates (closure-based style is valid) with targeted abstractions.
How-to for developers
When adding business behavior:
- Put rule/validation helpers in
domain/invariants/*. - Apply transitions inside entity methods.
- Throw domain errors from domain/entity boundary.
- In use-case: rehydrate -> call entity -> persist -> publish
pullEvents().
2) Standard module structure
Every module should follow the same skeleton:
domain/entities(required for write behavior)domain/invariants(reusable rule helpers)domain/events(event union)domain/errors(module domain error types)domain/value-objects(optional)application/dtos(commands/queries/results/aggregate state)application/ports(repo + service + event publisher ports)application/use-cases(orchestration only)adapters/http,adapters/persistence,adapters/events/subscriberscomposition.ts(wiring)
How-to for developers
When creating a new module:
- create the same folder skeleton first
- define aggregate state DTO and repo port
- define entity transitions in
domain/entities - wire use-cases and subscribers in composition
3) Domain boundaries: who depends on what
Boundary direction is strict:
adapters -> application -> domain- domain must not import Fastify/Prisma/Redis/SQS/contracts schemas
- application depends on ports, domain types, and module DTOs
- adapters implement ports and do framework/infrastructure work
This keeps the domain and use-cases testable and independent from delivery/storage details.
How-to for developers
Before writing code, check imports:
- If you are in
domain/, import only domain and module-level pure types. - If you are in
application/use-cases, import ports + domain helpers. - If you are in
adapters/*, this is where SDK/framework imports belong.
4) Why mappers exist
Mappers isolate boundaries where data shape may diverge over time:
- HTTP contracts shape ↔ module application DTO shape
- DB record shape ↔ module application DTO shape
- domain event shape ↔ external message shape (e.g. SQS payload)
Even when objects look identical today, mapping points are future-proof seams for versioning and decoupling.
Identity mappers (just return input) should be avoided unless they provide a meaningful seam or policy.
How-to for developers
Add mappers only at true boundaries:
- transport adapter (
handlers.ts) for request/response conversion - persistence adapter (
repo.impl.ts) for DB conversion - integration subscriber/publisher for external message conversion
If no transformation exists and likely will not, inline directly.
5) DTOs vs shared contracts
Use these ownership rules:
- shared contracts (
packages/contracts): API/wire schema and transport-level shapes. - application DTOs (
module/application/dtos/*): internal module commands/queries/results used by ports/use-cases.
So:
- HTTP adapter can depend on shared contracts and map to application DTOs.
- Use-cases and ports depend on application DTOs (not on transport contracts directly), including aggregate state DTOs used for rehydration.
- Persistence depends on application DTOs and DB models.
How-to for developers
For each new endpoint:
- define/update schema in shared contracts
- map contract request -> module command in HTTP adapter
- return module result -> map to contract response in HTTP adapter
- keep use-case and port signatures on module DTOs
6) Entities and aggregate state
Entities are used to centralize state transitions and prevent rule scattering across use-cases.
Current pattern in write use-cases:
- load
UserAggregateStatefrom repo - rehydrate entity (
rehydrateUserEntity(...)) - call entity behavior (
updateProfile,setInterests,softDelete) - persist resulting state through repo
- publish
entity.pullEvents()
Why this helps:
- one place for transitions and invariants
- predictable event production from domain behavior
- use-cases stay orchestration-focused
- persistence remains mapping/storage-only
How-to for developers
When adding a write operation:
- add/extend aggregate state DTO in
application/dtos/* - add/extend repo read method to fetch aggregate state
- implement transition in
domain/entities/* - update use-case to follow rehydrate -> act -> save -> publish events
- keep handlers/adapters free of transition logic
7) Events: created, composed, fired, listened
Event flow in this architecture:
- created as types in
domain/events/* - recorded inside entity methods and exposed via
pullEvents() - fired from use-cases through
DomainEventPublisherPort - composed/wired in module
composition.ts - listened by subscriber handlers (in-process and/or SQS)
Current users module example:
- use-case publishes
users.user-registered - composition wires handlers via shared event bus
- one handler logs, another publishes
welcome-email-requestto SQS
How-to for developers
To add a new domain event:
- add event type to
domain/events/* - push it from entity transition method
- publish pulled events from use-case after successful persistence
- implement subscriber in
adapters/events/subscribers/* - register handler in
composition.tshandlersByType
8) DDD techniques used (pragmatic, not strict)
Techniques used:
- bounded contexts by module (
users,auth,webhooks, etc.) - ubiquitous language in event/error/type names (
users.user-registered,USERS_*) - aggregate/entity boundary for state changes (
UserAggregateState+rehydrateUserEntity) - invariants as first-class domain functions
- domain events for side-effect decoupling
- ports/adapters to isolate infrastructure
- typed domain + infra errors for stable behavior and observability
Why this is not strict DDD:
- no full aggregate/repository/specification patterns everywhere
- mostly function-based modeling instead of class-heavy entity OO
- selective use of value objects only where they provide real value
- pragmatic trade-offs to keep implementation fast and clear
How-to for developers
Apply DDD tools only when justified:
- start with minimal aggregate behavior for write operations
- introduce value objects when primitive misuse repeats
- introduce aggregates when invariants span multiple entities/operations
- keep module language explicit in names and event codes
9) Fastify and global concerns
Root app (apps/api/src/index.ts) owns global concerns:
- request IDs, timing, telemetry
- request completion logging
- centralized error mapping (
AppError->{ code, message })
Modules should not duplicate global concerns; they focus on feature behavior.
How-to for developers
When adding cross-cutting logic:
- add it in app bootstrap (
index.ts) hooks/plugins if global - keep module handlers thin and feature-focused
- use typed
AppError/InfrastructureErrorfor stable global error responses
10) Forbidden patterns (must avoid)
- business validation/normalization inside HTTP handlers
- business rules inside persistence adapters
- transport contract types inside application/domain
- write use-cases bypassing entity methods for state transitions
- publishing domain events before persistence succeeds
How-to for developers
Before opening a PR:
- check each write use-case uses entity transition methods
- ensure handlers and repos contain mapping/orchestration only
- ensure event publish happens after successful save