
Multi-tenant recruiting platform: from careers site to auditable pipeline
Lab for a multi-tenant ATS in TypeScript to practice an end-to-end recruiting domain. Companies run in logical isolation per tenant, and the candidate keeps a single profile across applications. The focus is state machines, a per-job pipeline with auditing, and defense in depth for data access with Prisma over PostgreSQL. NestJS API, Next.js product, pre-signed uploads, and a public careers site in the same design.
Technical context
I modeled the product as a well-bounded modular monolith: a NestJS API exposes the domain through use cases, with JWT + RBAC and consistent input validation; the front end is Next.js with React, consuming the API with TypeScript contracts and TanStack Query to orchestrate server state. Persistence with Prisma and PostgreSQL in a shared schema: I traded the operational cost of database-per-tenant for the requirement of engineering discipline on every access (use cases + invariants + constraints). The candidate is not “one profile per company”: I treated 1 user → 1 global profile, which simplifies UX and consistency, but requires clear anonymization rules (LGPD-style) without destroying the operational trail of the hiring process.
For multi-tenant, I fixed the tenant on B2B routes from the token itself (avoiding trust in a loose header) and propagated context on the request; in parallel, I extended the Prisma client to apply filter/injection of tenantId on sensitive aggregates, as a safety net when some path forgets the filter. The trade-off is explicit: shared database with application-level RLS, instead of database-per-tenant. For media, I standardized pre-signed URLs (direct browser upload to S3-compatible storage) and persistence of only the object key, reducing surface area on the API and internal egress cost. Transactional email was treated as best effort: send failure becomes a log and does not roll back hire, reject, or application.
Engineering challenges (how and why)
- Two workflow axes in the application: I separated macro status movement (e.g., hired, rejected) from advancement through configurable stages (questionnaire, upload, interview link), each with history; this avoids mixing “business state” with “process UX” and keeps product evolution less coupled.
- Pipeline snapshot per job: I cloned the tenant template into job stages at creation time and freeze the design after publication; the trade-off is not retroactively changing old jobs, in exchange for predictability for recruiters and candidates.
- B2B onboarding without third-party-defined passwords: invites with opaque tokens delivered out of band, persisting only the hash in the database; if delivery fails, I revoke the invite so no active token remains without delivery.
- Email sourcing as an outcomes machine: the same endpoint chooses among registration invite, public job link delivery, and short-circuit when an application already exists; it does not create a “sourced” application, only picks the next step. That reduces support and ghost states.
- Controlled public exposure: site per company and aggregated marketplace reuse public endpoints with job visibility rules (draft does not leak), keeping the same domain base as the logged-in area.
- Operations and CI: health with liveness without dependencies and readiness checking PostgreSQL; integration pipeline separating API (tests + build + Prisma generation) and Web (check + build), aligned with team scale and localized failures.
- TypeScript
- NestJS
- Next.js
- Prisma
- PostgreSQL
- TanStack Query