architecture-layering-and-wiring

Layering and Composition Root

The YouTeacher content service — blog posts, categories, and file attachments — is built on DDD + Clean Architecture. The whole point of the layout is one rule: dependencies only ever point inward, toward the domain.

The four layers and the dependency rule

Source lives under src/ in four rings:

  • domain — business rules, invariants, entity behavior, value-object validation. Depends on nothing external. No Prisma, no Fastify, no DTOs.
  • application — use cases that orchestrate the domain, coordinate aggregates, and own transaction boundaries. Depends on the domain only, and reaches infrastructure through ports.
  • infrastructure — the adapters that implement those ports: Prisma repositories, S3/MinIO storage, Redis cache, auth and logging.
  • interfaces — the HTTP entry points (Fastify plugins, routes, Zod schemas). Each route parses input, calls one use case, and serializes the response.

The rule the project states as CRITICAL: interfaces → application → domain, with infrastructure hanging off application by implementing its ports. Domain depends on nothing; application depends on domain only; interfaces never touch infrastructure directly. The CLAUDE.md even enumerates the common violations — aggregating data in routes, business rules inside a repository, importing PrismaClient into a domain entity — as the failure modes this rule exists to prevent.

Ports and adapters

This is Dependency Inversion in practice: high-level code depends on abstractions, and the concrete adapter is chosen at wiring time.

There is a small subtlety worth noting. Not all ports live in one place. StoragePort and IdGenerator are declared in application/ports/ — for example StoragePort is a focused interface with uploadFeaturedImage, uploadAttachment, delete, and download, returning a plain { url, key } result, with the S3/MinIO implementation supplied by infrastructure. The PostRepository interface, by contrast, is imported from the domain (@/domain/post). So the repository contract is owned by the domain, while the storage and id-generation contracts sit one ring out in the application layer. Interface Segregation is deliberate — the repository port carries no upload methods.

The infrastructure adapter stays dumb

PrismaPostRepository implements PostRepository and does exactly one job: pure data access plus mapping between Prisma records and domain entities. Its private toDomain method rebuilds a Post (and its Attachment children) through Post.reconstitute / Attachment.reconstitute, so Prisma types never leak past the repository boundary. save runs an upsert plus attachment sync inside a single Prisma transaction, and retries on a pool-timeout error before giving up — that is infrastructure resilience, not a business rule. No domain logic lives here; anything like "is this post publicly live" belongs in the domain entity, not the query.

The composition root

Wiring happens in bootstrap/, kept apart from the layers it assembles.

  • bootstrap.ts (bootstrapDatabase) is the persistence composition root: it validates the database URL, ensures the schema exists, then constructs the Prisma repositories — post, category, page — injecting a single PrismaClient into each. It hands back a plain result object of those repositories for the rest of the app to consume.
  • createApp.ts (createApp) builds the Fastify instance: it registers CORS, a custom JSON content-type parser that tolerates empty bodies (so a bodyless POST /publish still parses), the service-auth plugin, health and env-check endpoints, a rewriteUrl hook that prefixes the API base path, and a not-found handler that logs and returns a structured 404.

The value of a composition root is that construction lives in one place. Because the domain defines contracts and infrastructure implements them, every repository implementation is interchangeable, and the wiring layer is the only code that knows which concrete adapter is plugged in.

Inside this node

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →