Skip to content

Repository files navigation

DDD PoC — Ticketing platform

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).

DDD in a dozen lines

  • 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; infrastructure implements 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).

How the code is organized

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)

Dependency injection

di/ is the mechanism, composition/bootstrap.ts is the policy:

  • Token<T> — a typed key, so resolve(Tokens.userService) returns a UserService with no cast.
  • Containerregister(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 call Bootstrap.web().resolve(...); worker/main.ts resolves 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.

How it works

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.

Scheduling without cron

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).

Testing (pnpm test)

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: just new and assert (Email normalization, User.register events, Event publish 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/) — the CircuitBreaker driven by an injected clock (deterministic, no fake timers).
  • DI (test/di/) — the container, including assembling a real UserService entirely 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.

Quick start

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` together

Example — 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

About

Typescript DDD PoC including classes for entity, repo & service. Event-driven via Kafka and BullMQ for async job broker

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages