Skip to content

Modular monolith vs microservices

One deployable with enforced modules first; move a module out only for a named difference, and pay for every call that then crosses the network.

Predict first

Before you read: predict

Five engineers are starting a ticket-resale product. The founders expect millions of users within two years, but no customer has used it yet, and none of the five has built or run a system of services.

How should version one be built?

Pick one

The 30-second version

Start as one deployable divided into modules that reach each other only through what each exports; NestJS checks this at startup. Moving a module into its own service lets it deploy and scale alone, and costs network calls, data split across databases and more to operate. Split for a different load, release pace or owning team, and say which.

One deployable with enforced modules first; move a module out only for a named difference, and pay for every call that then crosses the network.

How it works

Step 1 of 15

One deployable. A monolith builds, starts and scales as one application. The word says how it ships, not how tangled its code is.

Analogy: A family department store and its home-delivery department

One building, one set of doors, one opening time.

At the store In the system
each department serves the others across its own counter; staff from other departments are turned away at its stockroom door modules reached only through their exports, checked at once (steps 2, 4–5)
home delivery's rounds treble before Christmas, it loads vans at five in the morning, and it has its own manager a different scaling profile, release cadence and owner (step 13)
delivery moves to a depot on the ring road one module moved into its own service (step 8)
every parcel handed over is now a van trip, and the depot phones the store to check each address each call a network request, calls back included (step 9)
the evening stock count becomes two counts in two buildings data owned per service (step 10)
two buildings to staff, alarm and insure the operations premium (step 11)

In real terms: keep one deployable whose modules meet only at their exports, and move a module out only for a difference you can name.

Breaks down when: a van trip is visibly slow. In code a network call looks like any other method call, so its cost hides.

Contrasting case: a corner shop with one counter and one owner has nothing to wall off and nothing to move out.

Examples, run for real

4.1 ExamHall EXH-2370: a shortcut across a module boundary

EXH-2370: before ExamHall's API moved to NestJS modules, attempts/late.js imported Grading's score repository and wrote late submissions' points straight to the scores table. Grading's own path, record(), applies its late rule first. The script saves five submissions, two of them late:

4.1 EXH-2370 attempts/late.js imports grading/score.repository.js: 5 scores written · 2 skip Grading's late rule (a-4 58, a-5 71)
4.1 MR !2670 late path through GradingService.record(): 5 scores written · 0 skip the late rule (a-4 52, a-5 64) · imports of Grading's internals 0

MR !2670 moves the late path onto GradingService.record() and the code into NestJS modules, where Grading exports only GradingService (steps 2–4). MR !2671 later runs the BullMQ marking workers as their own deployment, because their load after an exam closes differs from the API's (step 13).

4.2 Proof domain, media streaming: one play request before and after a split

A streaming app's play handler calls Rights to check the viewer's licence, then Catalogue for the title's manifest, then signs the playback URL itself. The script runs one play request with every module in one process, then with Rights moved out, then with Rights and Catalogue moved out:

4.2 every module in one process: HTTP requests between services 0 · reply signed:tt-42.m3u8
4.2 Rights moved out: HTTP requests between services 1 (rights.check) · reply signed:tt-42.m3u8
4.2 Rights and Catalogue moved out: HTTP requests between services 2 (rights.check · catalogue.get) · reply signed:tt-42.m3u8

The code is unchanged; each call into a moved module became an HTTP request (step 9).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 5

ExamHall's API is one NestJS app. A developer wants a late submission's score saved the moment it arrives, so AttemptsService writes it through Grading's repository:

The app is started with NestFactory.createApplicationContext(AppModule), and then one late submission calls saveLate('a-7', 41).

When, if ever, does this fail, and how?

Code for this question
import { Injectable, Module } from '@nestjs/common'; @Injectable() export class ScoreRepository {  rows: { attemptId: string; points: number }[] = [];  write(attemptId: string, points: number) { this.rows.push({ attemptId, points }); }}@Injectable() export class GradingService { constructor(private scores: ScoreRepository) {} }@Module({ providers: [GradingService, ScoreRepository], exports: [GradingService] })export class GradingModule {} @Injectable() export class AttemptsService {  constructor(private scores: ScoreRepository) {}  saveLate(attemptId: string, points: number) { this.scores.write(attemptId, points); }}@Module({ imports: [GradingModule], providers: [AttemptsService] })export class AttemptsModule {} @Module({ imports: [AttemptsModule, GradingModule] })export class AppModule {}
Pick one

Question 2 of 5

A shop's checkout is one NestJS app with Orders, Fraud, Stock and Ledger modules. The checkout handler, in Orders, and Ledger's post:

async post(orderId) {
  const order = await this.orders.load(orderId);
  this.entries.push({ orderId, total: order.total });
}

The team moves Fraud, Stock and Ledger each into its own service, called over HTTP. Orders stays in the app, and each module keeps its code. A customer checks out a basket of 3 items.

After the move, how many HTTP requests between the shop's services does that one checkout cause?

Code for this question
async checkout(orderId, items) {  await this.fraud.screen(orderId);  for (const item of items) await this.stock.reserve(orderId, item);  await this.orders.save(orderId, items);  await this.fraud.record(orderId, 'placed');  await this.ledger.post(orderId);}
Pick one

Question 3 of 5

ExamHall's API is one NestJS app with Exams, Attempts, Grading and Proctoring modules, built by one team of six and released together twice a week. CI fails any import of another module's internal files (ESLint no-restricted-imports). Its architecture review receives four proposals to move a module into a service of its own.

Which proposal gives a reason the review should accept?

Pick one

Question 4 of 5

A video service's team of four plans to move transcoding out of its monolith, because transcoding needs GPU machines and the rest of the app does not. Their readiness notes:

  • Servers: a script starts a new GPU server in about an hour.
  • Deploys: a pipeline releases transcoding in 15 minutes, after one manual approval.
  • Errors: each GPU server writes them to its own log file, which an engineer reads at the weekly review.
  • First service: it holds upload, transcoding and packaging together.
  • Everything else: every other module stays in the monolith.

Which note should hold the split back until it changes?

Pick one

Question 5 of 5

A shop's catalog site is one NestJS app:

Startup throws UnknownDependenciesException.

Which one change makes the app start with CartService and SearchService holding the same PriceService instance?

Code for this question
import { Injectable, Module } from '@nestjs/common'; @Injectable() export class PriceService {  quote(sku: string) { return sku === 'mug' ? 8 : 12; }}@Module({ providers: [PriceService] })export class PricingModule {} @Injectable() export class CartService { constructor(public prices: PriceService) {} }@Module({ imports: [PricingModule], providers: [CartService] })export class CartModule {} @Injectable() export class SearchService { constructor(public prices: PriceService) {} }@Module({ imports: [PricingModule], providers: [SearchService] })export class SearchModule {} @Module({ imports: [CartModule, SearchModule] })export class AppModule {}
Pick one

Go deeper

Pro tips
  1. Forbid imports of another module's internal files with a lint rule (for example ESLint's no-restricted-imports): a new or a direct file import never passes through NestJS's injector, so no module rule sees it. When: reviewing a modular monolith's boundaries (steps 3–4). Verify (RAN, tips.mjs):
    tip 1 AttemptsService calls new ScoreRepository(): Nest starts · the late score reached Grading's repository: no
    tip 1 import check, files importing another module's internals: 1 (attempts/late.service.ts -> grading/score.repository)
Source

The Fowler pages are dated essays (2014 and 2015). The NestJS chapter is vendor documentation that changes with releases, so its claims are labelled as of 2026-10-01.

Checked against the tools and docs as of 2026-10-01.