A compact demo of Domain-Driven Design / Clean Architecture in TypeScript on a real stack: Next.js (API), PostgreSQL + Drizzle, Kafka (event bus), BullMQ + Redis (durable jobs), and a hand-rolled circuit breaker. It models three aggregates — Users, Events, Tickets. Everything is written with classes and interfaces (the only plain functions are Next.js route handlers and script entrypoints, which the framework/runtime require).
- Entities / Aggregate roots (
User,Event,Ticket) own their invariants and behavior —user.isDisabled(),event.isPublished(),ticket.isValid(). Nothing outside can put them in an invalid state. - Value Objects (
Email,PhoneNumber) are validated by construction and compared by value — once you hold one, it's correct. - Domain events (
UserRegistered,EventUpdated,TicketIssued) are recorded by aggregates and published after the write commits. - Repositories are interfaces (ports) declared by the domain; the database is an implementation detail.
- Application services orchestrate use cases (load → call domain → save → publish) and hold only cross-aggregate rules (uniqueness, capacity).
- The dependency rule points inward:
interface → application → domain;infrastructureimplements the inner ports. The domain depends on nothing. - Side effects are decoupled behind ports and driven by events, so the core never imports Kafka/HTTP/Postgres.
- Dependency injection wires it all together: classes receive their collaborators via constructors; a small IoC container resolves the graph from bindings declared in one composition root (
Bootstrap).
src/
domain/ Pure business model (no framework imports)
shared/ AggregateRoot, DomainEvent, IdGenerator, errors, VOs (Email, PhoneNumber)
user/ event/ ticket/ entity + repository PORT + domain events, per aggregate
application/
ports/ EventBus, ExternalUserApi, PdfGenerator (interfaces)
services/ UserService, EventService, TicketService (use cases)
handlers/ EventHandler classes run by the worker (SyncUserOnRegistered, …)
dto.ts output DTO classes (UserDto.fromDomain, …)
infrastructure/
persistence/sql/ Drizzle schema, mappers, SQL repositories
persistence/json/ JSON records, mappers, file-backed repositories
persistence/factory.ts RepositoryFactory — the PERSISTENCE swap point
messaging/kafka/ KafkaConnection, KafkaEventBus (implements EventBus)
messaging/bullmq/ RedisConnection, QueueRegistry
external/ mock external API + stub PDF generator + FaultInjector
resilience/ CircuitBreaker
interface/ ApiController + ErrorMapper + per-resource controllers
di/ IoC mechanism: Token<T>, Container (register/resolve), Tokens
composition/ Bootstrap — composition root: binds tokens → implementations
app/api/… Next.js route handlers (thin: delegate to controllers)
worker/ WorkerPool + KafkaToBullmqBridge + EventRoute (separate process)
di/ is the mechanism, composition/bootstrap.ts is the policy:
Token<T>— a typed key, soresolve(Tokens.userService)returns aUserServicewith no cast.Container—register(token, factory)+resolve(token), with lazy singletons (nothing connects to Postgres/Kafka until first resolved).Bootstrap— the one place naming concrete classes:Bootstrap.web()builds the API container (event bus + services),Bootstrap.worker()the worker container (adapters + handler routes). Controllers callBootstrap.web().resolve(...);worker/main.tsresolves its routes.
Swapping any implementation (e.g. an in-memory EventBus for tests, a Mongo repository) is a one-line change in Bootstrap — services, handlers, controllers and the domain are untouched.
HTTP route → Controller → Service → aggregate.record(event) + repo.save()
└→ EventBus.publish() → Kafka topic
│
KafkaToBullmqBridge (worker) ────────┘
└→ enqueue BullMQ job (idempotent by aggregateId)
└→ WorkerPool runs the EventHandler
└→ external API / PDF generation
(CircuitBreaker + retry/backoff)
- Kafka is the event log / fan-out (one topic per event name).
- BullMQ owns durable execution: jobs persisted in Redis, 5 attempts, exponential backoff, failures land in the dead-letter (failed) set.
- CircuitBreaker wraps each flaky downstream: 3 consecutive failures → open 10s → half-open probe.
Swap persistence with one env var — PERSISTENCE=sql (Drizzle/Postgres) or json (writes ./.data/*.json). Only RepositoryFactory reads it; domain, services, controllers, and worker are identical either way.
An Event has an endsAt, and we run an "event has ended" action at endsAt — with no cron and no polling. Kafka has no per-message delay, so the bridge translates the event into a delayed BullMQ job:
publish event → event.published (Kafka) → bridge schedules a delayed job
on the event-ended queue, delay = endsAt − now
… BullMQ holds it …
(at endsAt) → worker runs HandleEventEnded → post-event actions
The job carries a stable id (event-ended__<eventId>), so editing the schedule re-schedules it: PATCH /api/events/:id with a new endsAt emits event.updated (with endsAt in changedFields), and the bridge removes the pending job and re-adds it with the new delay. A cancelled event is handled defensively — the handler reloads the aggregate and no-ops if it's no longer live. One Kafka event can drive several reactions: event.updated both triggers a recompute and re-schedules the end job (route fan-out in Bootstrap.worker()).
For a watchable demo, create an event with a near-future window (the Postman collection uses startsAt +2min, endsAt +3min).
DDD makes features cheap to test because business rules live in pure objects and
side effects sit behind ports. The suite (Vitest, test/) walks the pyramid:
- Domain (
test/domain/) — value objects + entity invariants/behavior. No mocks, no I/O: justnewand assert (Emailnormalization,User.registerevents,Eventpublish rules,Ticket.isValid). - Application (
test/application/) — services wired to in-memory port fakes (test/fakes/):InMemoryUserRepository,RecordingEventBus. Asserts cross-aggregate rules (email uniqueness, oversell, disabled-user) and that the right domain events were published — all without Postgres or Kafka. - Infrastructure (
test/infrastructure/) — theCircuitBreakerdriven by an injected clock (deterministic, no fake timers). - DI (
test/di/) — the container, including assembling a realUserServiceentirely from fakes — the swappability payoff, demonstrated.
The fakes implement the same ports the real adapters do, so the code under test is byte-for-byte what runs in production — only the edges change.
pnpm install
cp .env.example .env # set DATABASE_URL; createdb dddpoc
pnpm infra:up # Kafka + Redis (+ Kafka UI on :8080)
pnpm db:generate && pnpm db:migrate
pnpm seed # optional sample data (respects PERSISTENCE)
mprocs # runs `pnpm dev` (API :3000) + `pnpm worker` togetherExample — create a user (emits user.registered → Kafka → BullMQ → mock external API; watch the worker log retry/circuit-break, tune EXTERNAL_API_FAILURE_RATE):
curl -sX POST localhost:3000/api/users -H 'content-type: application/json' \
-d '{"name":"Ada Lovelace","email":"ada@example.com","phone":"+33 1 23 45 67 89"}'Then publish an event (POST /api/events, POST /api/events/:id/publish) and issue a ticket (POST /api/tickets) → a stub PDF appears in ./storage/pdfs/.
Prefer a GUI? Import postman/dddpoc.postman_collection.json — it chains the IDs automatically so you can run the whole happy path (create user → event → publish → issue ticket → use/refund) in order. See postman/README.md.
| Command | What |
|---|---|
mprocs |
API + worker together |
pnpm dev / pnpm worker |
run either alone |
pnpm infra:up / infra:down |
Kafka + Redis |
pnpm db:generate / db:migrate / db:studio |
Drizzle |
pnpm seed |
sample data |
pnpm test / pnpm test:watch |
Vitest suite |
pnpm typecheck |
tsc --noEmit |