The youteacher_admin service is the operator-facing backend, and it is laid out as a hexagonal (ports-and-adapters) application with four concentric layers. The dependency arrows all point inward: outer layers know about inner ones, never the reverse.
- interfaces — the Fastify HTTP surface:
createApp, theAdminHttpControllerroute registrar, and plugins. - application — use cases, one per operation family (e.g.
EmployerVerificationUseCase). They orchestrate; they hold no framework and no transport code. - domain — aggregates and value objects (
Employer,VerificationStatus,OwnershipStatus) plus the repository interfaces the application depends on. This is the layer that owns the rules. - infrastructure — the adapters that satisfy those interfaces: HTTP repositories, a
ServiceClient, auth helpers, an in-memory cache and event bus.
Wiring: bootstrap, then create the app
Construction is a pure function. bootstrapService(config) takes the downstream service URLs plus a shared JWT secret and, in one pass, builds:
- one
ServiceClientper downstream service (profile, auth, content, job), each stamped withcallerService: "admin"; - non-request-scoped infrastructure — a dashboard cache and an event bus, each defaulting to an in-memory implementation;
- the auth stack (
AuthSessionClient→UserAuthHelper); - the HTTP repositories and HTTP services, each handed the client it talks through;
- the use cases, each handed the domain interfaces it needs.
The result is a plain deps bag. createApp({ jwtSecret, deps }) then registers Fastify plugins and calls registerAdminRoutes(app, deps). Nothing constructs its own dependencies — everything is injected from the one bootstrap pass, which is what keeps the domain and application layers free of transport concerns.
A request, end to end
- URL rewrite. Fastify's
rewriteUrlprefixes every incoming path with the API base, except health, env-check, and internal (/__) paths, so handlers can be written against clean paths. - Request context. An
onRequesthook captures the inbound session cookie into@fastify/request-context, so repositories can forward the caller's identity downstream without threading it through every function. - Auth. A service-auth plugin runs, then a second
onRequesthook in the controller enforces admin authentication on every route — public routes (health) and the diagnostic env-check are the only exceptions. Non-admins never reach a handler. - Handler → use case. The route handler unpacks the request and calls a single application use case.
- Use case → domain. The use case loads an aggregate through a repository interface, invokes a domain method that enforces the rule (e.g. an employer can only be verified from the pending state), then asks the repository to persist the new state.
- Repository → downstream. The HTTP repository reads the forwarded headers from request context, calls the profile service through its
ServiceClient, and maps the raw response back into a domain aggregate viareconstitute. A 404 from downstream becomes anull, which the use case turns into a not-found error rather than a crash. - Response / errors. A central error handler translates typed errors into HTTP: a domain/HTTP error carries its own status and code, a validation error becomes 400, and anything else becomes a generic 500 — so no stack trace leaks to the caller.
The shape repeats for every domain (recruiter, quota, talent, claim, content, job, dashboard): handler → use case → domain aggregate → HTTP repository → downstream service. The service holds no database of its own; it is an orchestration and policy layer in front of the profile, auth, content, and job services.
Why it's built this way
The domain rule lives in exactly one place — the aggregate — and the application layer only sequences calls, so the transport (HTTP-to-downstream) is a swappable adapter behind a repository interface. Header forwarding through request context, rather than through argument lists, keeps that seam clean: a use case never sees a cookie or a JWT.