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. TheInvitationaggregate root is the clearest example: a private constructor, a staticcreatefactory that mints the id, generates a cryptographically random token via Node's owncrypto, and computes the expiry date (default seven days); afromPropsreconstitution path for loading from storage and atoPropsfor writing back; and status-transition methods (markAsViewed,accept,decline) that enforce the rules —accept/declinethrow unless the invitationcanRespond,markAsViewedonly fires while pending.isExpiredandisDuplicatealso 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 splitscommands/fromqueries/:SendInvitation,AcceptInvitation,DeclineInvitation,UpdateInvitation,SendContact,UnlockTalentare commands;GetInvitationByToken,GetMyInvitations,GetSentInvitations,GetTalentDetail,SearchTalents,GetUnlockedTalentsare queries. A handler takes its collaborators through the constructor and does one thing inexecute.UnlockTalentHandler, for instance, is handed anUnlockRepository, checks for an existing unlock (returningalreadyUnlockedwhen found — idempotent by design), otherwise builds a domainTalentUnlockand persists itstoProps(). - Infrastructure (
src/infrastructure/) — the concrete adapters that satisfy the interfaces the application declares:PrismaInvitationRepository,PrismaContactRepository,PrismaUnlockRepositoryback the repository ports with Prisma; there is aDatabaseBootstrapper, aUserAuthHelper, and file storage. These are the only places that know about the database or external systems. - Interfaces (
src/interfaces/rest/) — the Fastify delivery layer:TalentsHttpControllerand anInternalControllerfor service-to-service calls, plus aserviceAuthPlugin. 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:
bootstrap.ts—bootstrapDatabase(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.createHandlers.ts— takes the repositories, aTalentSearchService, 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.createApp.ts— builds the Fastify instance: registers CORS and the service-auth plugin, installs a single error handler that turnsHttpErrorandZodErrorinto 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. ArewriteUrlhook 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.