architecture-and-layering

Service Role and Hexagonal + CQRS Architecture

The talent service is one module of YouTeacher. It owns talent search, employer/recruiter invitations, talent unlocks, and contact. Its code is cut along the classic hexagonal (ports-and-adapters) lines, with commands and queries kept apart (CQRS), and a single bootstrap step that wires the concrete pieces together at startup.

The four layers

The source tree separates concerns into four inward-pointing rings, plus config and bootstrap around them.

  • Domain (src/domain/) — pure business objects with no framework or database in sight. The Invitation aggregate root is the clearest example: a private constructor, a static create factory that mints the id, generates a cryptographically random token via Node's own crypto, and computes the expiry date (default seven days); a fromProps reconstitution path for loading from storage and a toProps for writing back; and status-transition methods (markAsViewed, accept, decline) that enforce the rules — accept/decline throw unless the invitation canRespond, markAsViewed only fires while pending. isExpired and isDuplicate also live here. Nothing in this file imports infrastructure.
  • Application (src/application/) — command and query handlers, grouped by feature (invitation/, unlock/, search/, contact/, upsert/). Each feature splits commands/ from queries/: SendInvitation, AcceptInvitation, DeclineInvitation, UpdateInvitation, SendContact, UnlockTalent are commands; GetInvitationByToken, GetMyInvitations, GetSentInvitations, GetTalentDetail, SearchTalents, GetUnlockedTalents are queries. A handler takes its collaborators through the constructor and does one thing in execute. UnlockTalentHandler, for instance, is handed an UnlockRepository, checks for an existing unlock (returning alreadyUnlocked when found — idempotent by design), otherwise builds a domain TalentUnlock and persists its toProps().
  • Infrastructure (src/infrastructure/) — the concrete adapters that satisfy the interfaces the application declares: PrismaInvitationRepository, PrismaContactRepository, PrismaUnlockRepository back the repository ports with Prisma; there is a DatabaseBootstrapper, a UserAuthHelper, and file storage. These are the only places that know about the database or external systems.
  • Interfaces (src/interfaces/rest/) — the Fastify delivery layer: TalentsHttpController and an InternalController for service-to-service calls, plus a serviceAuthPlugin. Controllers translate HTTP into handler calls and back.

Ports and their owners

The dependency arrows point inward. The application layer owns the interfaces it needs — InvitationRepository, ContactRepository, UnlockRepository all live under src/application/…, and so do ports like SearchCachePort. Infrastructure implements them. That inversion is what lets the domain and handlers stay ignorant of Prisma: a handler depends on UnlockRepository, not on PrismaUnlockRepository. Some collaborators are optional — FileStorage and SearchCachePort are passed as optional arguments, and the code degrades gracefully when they are absent (the attachment-download handler is only constructed when file storage exists).

Routes come from the environment

src/config/routes.ts reads every path from an environment variable through a requireEnv helper that throws when a variable is missing. Route paths (search, detail, invitations, unlock, contact, the internal users endpoint, the API base) are therefore not hard-coded — they are configuration, and a misconfigured deployment fails fast at boot rather than serving the wrong path.

Bootstrap: the composition root

Three files under src/bootstrap/ assemble the running service, and they are the only place where concrete classes meet abstract ones:

  1. bootstrap.tsbootstrapDatabase(prisma) ensures the tables exist (DatabaseBootstrapper.ensureTables) and constructs the three Prisma repositories. This same code path is used by both production entry (main.ts) and the tests.
  2. createHandlers.ts — takes the repositories, a TalentSearchService, and the optional cache and file-storage ports, and returns the full set of handlers, injecting each one's dependencies. This is where a repository interface is bound to its concrete instance.
  3. createApp.ts — builds the Fastify instance: registers CORS and the service-auth plugin, installs a single error handler that turns HttpError and ZodError into a structured { code, message } response (and everything else into a generic 500, so raw stack traces never reach a client), exposes health and env-check endpoints, and registers the talent controller. The internal controller is only mounted when all three repositories are supplied. A rewriteUrl hook normalizes incoming paths against the configured API base.

The payoff of this shape: the domain is testable in isolation, handlers can be exercised against fake repositories, and swapping a persistence adapter is a one-line change in the bootstrap — nothing in the domain or application layers moves.

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 →