How We Structure NestJS, Prisma and PostgreSQL Backends
Our default backend stack is NestJS on PostgreSQL, with Redis for queues and caching and Elasticsearch for search. Here is how we structure it and why.
For most products we build, the backend is NestJS on PostgreSQL, with Redis for caching and background jobs and Elasticsearch when search matters. Where we use an ORM on that stack, it is usually Prisma. It is a deliberately unexciting stack: typed end to end, well documented and easy to hire for.
This post walks through how we structure these backends, using two real projects as reference points. Digital Arrow, a recruiting and HR platform, runs on NestJS, PostgreSQL, Prisma, Redis, Elasticsearch, MinIO and Browserless. Ealthiness, a fitness app, uses NestJS, PostgreSQL, Redis and Elasticsearch behind a React Native client.
Modules follow the domain
NestJS gives you modules, and the most important architectural decision is how you draw them. We organise by business domain, not technical layer. On a platform like Digital Arrow, that means modules along the lines of jobs, candidates, interviews, timesheets, contracts and invoicing, rather than one big 'services' folder.
- Each module owns its controllers, services, DTOs and tests.
- Modules expose a small public service; other modules call that service, not each other's database tables directly.
- Cross-cutting concerns (auth guards, logging, error filters, config) live in a shared core module.
- Validation happens at the edge with DTOs, so services can trust their inputs.
This gives you a modular monolith: one deployable application with clear internal boundaries. It scales horizontally without trouble, and if one module later needs to become its own service, the boundary is already there.
Prisma and PostgreSQL: schema as the source of truth
With Prisma, the schema file is the single source of truth for the data model, and the generated client gives typed queries throughout the codebase. A renamed column breaks the build rather than a production request. Our conventions:
- Every schema change is a migration, committed with the code that needs it and reviewed like any other change.
- Migrations run automatically in CI/CD before the new version starts serving traffic, never by hand against production.
- Destructive changes are split into expand and contract steps: add the new column, backfill, switch reads, then drop the old one in a later release.
- Indexes are added for real query patterns, checked with the query plan, not guessed in advance.
- Raw SQL is allowed where Prisma is awkward, such as complex reporting queries, and kept inside the owning module.
PostgreSQL does a lot of work for us beyond plain tables: constraints and foreign keys enforce integrity, JSONB holds genuinely flexible data, and transactions keep multi-step operations consistent, for example creating a contract and its signature request together.
Redis for caching and queues
Anything slow, unreliable or not needed for the immediate response goes into a background queue. We use Redis-backed queues (BullMQ, through the NestJS integration) for emails, notifications, search indexing, webhook delivery, media processing and document generation.
On Digital Arrow, generating contract and CV PDFs with headless Chrome through Browserless is a good example: it is too slow and resource-hungry to run inside an API request, so it runs as a job, and the user is notified when the document is ready. Media such as interview videos is stored in MinIO, S3-compatible object storage, rather than on the API servers.
@Processor("documents")
export class DocumentsProcessor extends WorkerHost {
constructor(private readonly pdf: PdfService) {
super();
}
async process(job: Job<{ contractId: string }>) {
const file = await this.pdf.renderContract(job.data.contractId);
await this.pdf.store(job.data.contractId, file);
}
}Jobs are idempotent, so a retry never produces a duplicate email or document, and failed jobs are visible in monitoring rather than disappearing silently. Redis also holds short-lived cache entries and rate-limit counters.
Elasticsearch for search, PostgreSQL for truth
PostgreSQL full-text search goes a long way, but once users expect fuzzy matching, relevance ranking, facets and fast filtering across large text fields, a dedicated search engine earns its place. Digital Arrow uses Elasticsearch for full-text candidate search across CVs and profiles.
The rule we follow is simple: PostgreSQL is the source of truth, Elasticsearch is a derived index. Writes go to the database; a queue job then updates the search index. If the index is ever lost or the mapping changes, we rebuild it from the database. That keeps search fast without ever risking data integrity.
Testing that catches real problems
Our testing is weighted towards the places bugs actually occur:
- Unit tests for pure business rules: pricing, permissions, state transitions.
- Integration tests that run services against a real PostgreSQL database in a container, because mocked ORMs hide the bugs that matter.
- End-to-end API tests for critical flows such as sign-up, payments and document signing.
- Migration checks in CI, applying every migration to a fresh database to make sure the history is consistent.
API design and documentation
Most of our backends serve a Next.js web app and often a React Native client. We generate an OpenAPI description from the NestJS controllers and DTOs, and use it to produce typed clients for the frontends. That keeps web, mobile and backend in agreement about every field, and it gives partners and future team members accurate API documentation for free.
We version deliberately. Additive changes, such as new optional fields or new endpoints, ship freely. Breaking changes get a new endpoint or version and a deprecation period, which matters most when mobile users may be several app versions behind.
Authentication and permissions
Most of our platforms serve several kinds of user. Digital Arrow, for example, covers recruiters, candidates and client companies, each seeing different data. We handle this with NestJS guards that check both who the user is and what they are allowed to do on the specific resource, not just their role. Permission logic lives in one place per module and is covered by unit tests, because permission bugs are among the most damaging and least visible failures a multi-tenant system can have.
Every query that touches tenant data is scoped by the tenant identifier at the service layer, so a missing filter in a controller cannot leak another company's records.
Operations and observability
The API runs stateless, so it scales by adding instances. Configuration comes from the environment, secrets never live in the repository, and health checks cover the database, Redis and search. Structured logs carry a request ID through the API and into any jobs it triggers, which makes tracing a problem across the system straightforward. For more on scaling this kind of service, see how to scale a Node.js application.
Building your backend with us
If you are starting a new platform or need an existing NestJS backend reviewed, we are happy to talk through your architecture. See our web development service, read the Digital Arrow case study, or contact us.
