AWS Apr 18, 2026 · 9 min read

From monolith to per-audience backends: migration lessons

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

For almost three years, a single NestJS backend served us well. One monolith, one deploy, one mental model. Until it didn't. There was no dramatic outage, no production incident: it was the daily friction of touching one portal's code and having to think about the other three. At JXBS we build an AI-first recruitment SaaS with several portals for distinct audiences —candidates, companies, internal admin— and they all lived inside the same backend. This is the story of why we split it into per-audience backends, and the things nobody warns you about regarding pnpm and NestJS dependency injection when you do.

Why one backend stopped scaling with us

The problem was never performance. It was coupling and the blast radius of every deploy.

When everything lives under one NestJS root module, the boundaries blur on their own. A candidates service starts importing something from the companies module "just for now." A guard meant for admin sneaks into a public route. And since it's a single process, everything shares the same config, the same PostgreSQL connection pool, the same startup cycle.

Three concrete symptoms pushed us to move:

  • Deploy blast radius. A change in the admin portal forced a redeploy of the entire backend. If something broke, it all went down, including the public candidates portal that hadn't changed in weeks.
  • Mixed audiences. Different portals have different auth requirements, different rate limits, different exposure surface. Cramming them into one process meant the most sensitive route set the paranoia level for everything.
  • Shared cognition. Every person on the team had to load the whole domain into their head just to touch a small part of it.

What "per-audience backends" means

The idea is simple: one backend per portal. One for candidates, one for companies, one for admin. Each is its own independent NestJS 11 service, with its own deploy, its own config, and its own API surface. The frontend stays Next.js 15 with React 19, and each portal talks to its backend.

What does not change is the database or the domain. We're still on PostgreSQL + pgvector with Prisma. The trick is that shared code —entities, domain logic, utilities, the Prisma client— lives in monorepo packages (Turborepo + pnpm), not duplicated across services.

apps/
  candidates-api/        # NestJS 11 — candidates portal
  companies-api/         # NestJS 11 — companies portal
  admin-api/             # NestJS 11 — admin portal
  web-candidates/        # Next.js 15
  web-companies/         # Next.js 15
packages/
  domain/                # pure domain logic, no NestJS
  database/              # Prisma client + repos
  auth/                  # reusable auth module
  config/                # env loading + validation

The rule that saved us: packages under packages/ expose classes and functions; the apps/ decide how to wire them. The domain doesn't know which backend it's running in.

The pnpm + dependency-injection traps nobody mentions

This is where I lost hours. NestJS assumes a provider is a singleton within its container. pnpm, with its strict node_modules layout and its symlinks, can quietly break that assumption.

The first hit: duplicated provider instances. If a shared package declares a provider and two different modules import it via slightly different paths, you end up with two instances. A "singleton" that isn't one. You notice it when an in-memory cache or a connection pool behaves inconsistently.

The cause is almost always the same: the provider token isn't identical across importers, or the shared dependency got installed in two places in the tree.

The pattern that avoids it: a global dynamic module with an explicit token, declared exactly once.

// packages/database/src/database.module.ts
import { Global, Module, DynamicModule } from '@nestjs/common';
import { PrismaService } from './prisma.service';

export const PRISMA = Symbol('PRISMA'); // stable, unique token

@Global()
@Module({})
export class DatabaseModule {
  static forRoot(): DynamicModule {
    return {
      module: DatabaseModule,
      providers: [{ provide: PRISMA, useClass: PrismaService }],
      exports: [PRISMA],
    };
  }
}

Each app calls DatabaseModule.forRoot() once in its root module. The token is an exported Symbol, so there's no string ambiguity and no collision risk.

The other two that bit me:

  • Peer deps, not direct deps. Shared packages declare @nestjs/common and @nestjs/core as peerDependencies, never as regular dependencies. If a package ships its own copy of NestJS, its decorators and metadata live in a different realm and DI simply won't recognize them. In pnpm-workspace.yaml, leaning on the catalog to pin a single version helps a lot.
  • Module boundaries, not folder boundaries. A package can export code; whether it's a NestJS @Module is a separate decision. We kept packages/domain NestJS-free —pure classes and functions— and only packages/auth and packages/database expose modules. That way the domain gets reused without dragging the DI container everywhere.

💡 In a monorepo, a "singleton" is only a singleton if the token is identical and the dependency is installed exactly once. pnpm won't warn you when that's false: it tells you in production.

How to share domain logic without recoupling it

The obvious risk of splitting the monolith is rebuilding the coupling inside the packages. A shared package that knows everything is the monolith with a new name.

What worked:

  • The domain imports no infrastructure. packages/domain knows nothing about Prisma or NestJS. It takes interfaces; the apps inject implementations.
  • One package, one responsibility. auth, database, config kept separate. If you're unsure which package something belongs to, it probably belongs to the app.
  • No cross-backend dependencies. candidates-api never imports from companies-api. If they need to talk, it's over an API or a shared contract in packages/, never a direct import.

Deploying multiple services on ECS Fargate

Each backend is its own service on ECS Fargate: its own task definition, its own image, its own auto-scaling. We manage CI/CD with OpenTofu, so adding a backend is declaring a new module, not clicking through a console.

Turborepo gives us the other pillar: affected builds. Only what changed gets built and deployed. Touching admin-api doesn't rebuild candidates-api. That was the original goal —shrinking the blast radius— and this is where it becomes real.

The detail that watches the budget: each service sizes its CPU and memory to its own load. The candidates portal and the admin portal don't have to pay for the same task size.

Doing it without a big-bang

We rewrote nothing all at once. We extracted one backend first —the lowest-risk one— leaving the monolith serving the rest. Portal by portal, we moved routes to the new service and pointed the corresponding frontend at it. The monolith kept shrinking until it was gone.

What I'd tell my past self

Draw the audience boundaries from day one, even if everything lives in a single process at first. Separating modules with clear limits is cheap; untangling a coupled monolith is expensive. And treat dependency injection in a pnpm monorepo as what it really is: a question of instance identity, not of imports. Pin the versions, use explicit tokens, keep the domain pure. The rest follows.

Yohangel Ramos

Written by Yohangel Ramos

Senior Fullstack Developer and Tech Lead. I build with React, Next.js, Nest.js and AWS — and I write about what I learn along the way.

Let's talk →

Keep reading

IA

AI-built startups in 2026: the real numbers behind the hype

IA

How to survive as a programmer in the AI era (without turning cynical or naive)