Profile Service — DDD/CQRS Architecture and Dependency Wiring
The profile service is YouTeacher's richest data surface — teacher profiles, employer (school) profiles, recruiter profiles, and the unlock records that gate access to a teacher's contact details. Internally it is laid out as four concentric layers, and everything is stitched together in one place at boot. This node is about that shape and that stitching, not about any single feature.
The four layers
The dependency rule points inward: outer layers know inner ones, never the reverse.
- Domain — the core. Entity classes carry their own behaviour (
TalentProfile,EmployerProfile,RecruiterProfile), value objects carry rules (Quota), and cross-aggregate logic lives in domain services (UnlockService). A 2026-01 audit (DDD_AUDIT.md) records the deliberate move away from an anemic model: business methods likeemployer.canUnlockTalent(),talent.isVisibleTo('school'), andquota.hasRemaining()now live on the domain objects rather than leaking into handlers. Crucially, the repository interfaces live here (domain/talent/TalentRepository.ts, etc.) — the domain declares what persistence it needs, and infrastructure implements it. That is dependency inversion made structural. - Application — thin, per-use-case handlers organised as commands (and queries) under each aggregate, e.g.
application/employer/commands/UnlockTalent.tsand its recruiter twin. Their job is orchestration only: load aggregates through repository interfaces, call domain methods, persist. The audit is explicit that the two unlock commands both delegate to the sharedUnlockServicerather than duplicating the rules. - Infrastructure — the concrete adapters that satisfy the domain's interfaces: Prisma repositories over PostgreSQL, an S3-compatible file store, a Redis-backed rate limiter, a BullMQ talent-sync adapter, and HTTP clients to the auth and talent-search services.
- Interfaces — Fastify controllers, one registrar per aggregate (
registerProfileRoutes,registerTalentRoutes,registerEmployerRoutes, …), plus the service-auth plugin and error handling.
UnlockService: a domain service done right
UnlockService is the clearest window into the domain style. It validates whether an employer or recruiter may unlock a teacher by asking the aggregates themselves three questions in order — is the viewer verified/approved, is the talent visible to that viewer type, and is there unlock quota remaining — and returns a typed { canUnlock, error } result with a stable error code for each failure. No SQL, no HTTP, no framework: pure rules over domain objects. Because both the employer and recruiter unlock commands call it, the gate can only be defined once.
The composition root: createApp
All wiring happens in bootstrap/createApp.ts. It is deliberately the only place that knows how to build a concrete dependency:
initializeDependenciesconstructs every adapter — Prisma repositories, the file store, rate limiter, talent-sync queue adapter, the auth and talent-search HTTP clients, and the session-basedUserAuthHelper. Each has an??fallback: if a caller passes one in (AppDependencies), that wins. The comment says why — "inject to avoid shared singletons in tests" — so a test can hand in fakes and get an isolated app.registerAllRoutesthen threads those already-built dependencies into each controller registrar. Controllers nevernewtheir own repositories; they receive exactly the ports they use.- The Fastify instance itself registers CORS, cookies, multipart uploads, and the service-auth plugin; installs a tolerant JSON body parser; and sets one error handler that maps
HttpErrorand Zod validation failures to clean status codes, with everything else collapsing to a generic 500 (no stack traces leak to the caller).
This is the practical payoff of putting repository interfaces in the domain: the whole graph is assembled from the outside in a single function, and the inner layers stay ignorant of Prisma, Redis, S3, and Fastify entirely.
Config as an inward-facing contract: config/routes.ts
Every route path — and every downstream service path — is read from an environment variable via a requireEnv helper that throws at boot if the variable is missing. Paths are stored complete (e.g. the full profile-me path), not assembled from a base plus a suffix. A PUBLIC_ROUTES list names the handful of endpoints that opt out of service-to-service auth (health, skills, a couple of public lookups). The service authenticates inbound calls with a signed service credential and resolves the acting user from a session via the auth service; this node describes only that two-layer shape — the credential mechanics belong to dual-auth-service-jwt-user-session and auth. Fail-fast config means a misconfigured deploy dies at startup rather than 404-ing in production.
Persistence: PrismaTalentRepository as a representative adapter
The Prisma repositories implement the domain interfaces and are responsible for one translation: rows ↔ domain class instances (mapToTalentProfile returns a real TalentProfile, not a plain object). Two habits are worth noting:
- Transactions wrap multi-table reads and writes. Fetching a talent plus its skills and credentials, or updating skills, all run inside
$transactionso a profile is never assembled from a half-written state. - Deletes cascade in application code. The Prisma schema (
schema.prisma) defines no foreign-key relations between tables, so deleting a talent explicitly removes its skill links, credentials, and unlock rows before the profile itself. The schema is otherwise a straightforward snake-cased mapping (@@map("talent_profiles"),@map(...)), with quota tracked as plain integer columns on the employer and recruiter models and a uniqueness constraint on(talentId, viewerId, viewerType)guarding double-unlocks.
Why it is built this way
The layering is not decoration. Repository interfaces in the domain make createApp the single seam where the real world is plugged in — which is exactly what lets the test harness swap in fakes, and what keeps a rule like "who may unlock a teacher" expressed once, in one small pure class, instead of scattered across controllers.