Skip to main content

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:

  1. Put rule/validation helpers in domain/invariants/*.
  2. Apply transitions inside entity methods.
  3. Throw domain errors from domain/entity boundary.
  4. 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/subscribers
  • composition.ts (wiring)

How-to for developers

When creating a new module:

  1. create the same folder skeleton first
  2. define aggregate state DTO and repo port
  3. define entity transitions in domain/entities
  4. 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:

  1. transport adapter (handlers.ts) for request/response conversion
  2. persistence adapter (repo.impl.ts) for DB conversion
  3. 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:

  1. define/update schema in shared contracts
  2. map contract request -> module command in HTTP adapter
  3. return module result -> map to contract response in HTTP adapter
  4. 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:

  1. load UserAggregateState from repo
  2. rehydrate entity (rehydrateUserEntity(...))
  3. call entity behavior (updateProfile, setInterests, softDelete)
  4. persist resulting state through repo
  5. 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:

  1. add/extend aggregate state DTO in application/dtos/*
  2. add/extend repo read method to fetch aggregate state
  3. implement transition in domain/entities/*
  4. update use-case to follow rehydrate -> act -> save -> publish events
  5. keep handlers/adapters free of transition logic

7) Events: created, composed, fired, listened

Event flow in this architecture:

  1. created as types in domain/events/*
  2. recorded inside entity methods and exposed via pullEvents()
  3. fired from use-cases through DomainEventPublisherPort
  4. composed/wired in module composition.ts
  5. 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-request to SQS

How-to for developers

To add a new domain event:

  1. add event type to domain/events/*
  2. push it from entity transition method
  3. publish pulled events from use-case after successful persistence
  4. implement subscriber in adapters/events/subscribers/*
  5. register handler in composition.ts handlersByType

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:

  1. add it in app bootstrap (index.ts) hooks/plugins if global
  2. keep module handlers thin and feature-focused
  3. use typed AppError/InfrastructureError for 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:

  1. check each write use-case uses entity transition methods
  2. ensure handlers and repos contain mapping/orchestration only
  3. ensure event publish happens after successful save