For most new products a sensible start is a modular monolith: one application and one deployment, but modules with a public API, their own data and an automated boundary check. Microservices give independent deployment and team autonomy, but add network calls, eventual consistency and operational complexity — Martin Fowler calls this the “microservice premium”. Extract a service when a module's boundary has settled and its part of the system has gained a separate team or special requirements for scale, availability or security.
After this you will be able to
- compare a modular monolith and microservices by team, domain, deployment, data and observability and justify the choice for your product
- list the components of the cost of distribution and the prerequisites before extracting the first service
- structure modules with a public API, split their data into PostgreSQL schemas and check boundaries with a
dependency-cruiserrule in CI - plan a gradual strangler fig migration and pick the modules that are candidates for services
Know beforehand
Verified with
Node.js 24 LTS (24.18)TypeScript 5.9.3 (tsc --strict), tsc 7.0.2Vitest 5.0.3dependency-cruiser 18.6.0PostgreSQL 17.11 (Docker, postgres:17-alpine)
Checked against docs: October 11, 2026
Contents
Three shapes of one system
A monolith is not an insult. microservices.io defines the monolithic architecture as an application structured as a single deployable component that uses a single database, where all operations are local. Fowler notes that in the microservices world “monolith” usually means only how the application is built — as a single unit — not a poor code structure.
Poor structure is called a Big Ball of Mud, and it is possible in any shape. Fowler writes that in theory nothing stops a monolith from having well-defined module boundaries, but in practice they are too easy to breach. A modular monolith is a monolith where boundaries are not just drawn but protected: each module has a public API, its own data and a CI check that prevents going around the API.
Unit of deployment
- Monolith without boundaries
- One
- Modular monolith
- One
- Microservices
- Many, each with its own pipeline
Calling another part of the system
- Monolith without boundaries
- Any function, any import
- Modular monolith
- A function call through the module's public API
- Microservices
- A network request or a message
Data
- Monolith without boundaries
- Shared tables, anyone reads anything
- Modular monolith
- One database, the module owns its tables or schema
- Microservices
- A service's data is reachable only through its API
A transaction across several parts
- Monolith without boundaries
- A plain ACID transaction
- Modular monolith
- Possible, but it couples modules
- Microservices
- Eventual consistency only: sagas, events
What protects the boundaries
- Monolith without boundaries
- Nothing
- Modular monolith
- A dependency linter, database permissions
- Microservices
- The network and separate repositories
Criteria: team, domain, deployment, data, observability
Team
- Modular monolith
- One or several teams in one codebase, releases need coordination
- Microservices
- Small independent teams, each owns its services
- Ask yourself
- How many teams actually get in each other's way when releasing?
Domain
- Modular monolith
- Boundaries can be moved with a one-day refactoring
- Microservices
- Moving responsibility between services is a coordinated API and data migration
- Ask yourself
- How settled are the domain boundaries?
Deployment
- Modular monolith
- One artifact, one pipeline; several copies can run behind a load balancer
- Microservices
- Independent releases, but you need automation, API versioning, compatibility
- Ask yourself
- Is there automated deploy and rollback for every component?
Data
- Modular monolith
- ACID transactions across modules, JOINs in reports
- Microservices
- Database per service: cross-service operations are sagas, queries are API composition or separate read models
- Ask yourself
- Are there operations that must atomically change data of different parts?
Observability
- Modular monolith
- One log, one call stack, debugging in one process
- Microservices
- Distributed tracing, log correlation, metrics per service
- Ask yourself
- Can you find which service slowed a request down?
Scale and special requirements
- Modular monolith
- The whole application scales as one
- Microservices
- A part with special load, availability or security needs runs separately
- Ask yourself
- Is there a part that needs different resources or a different level of protection?
microservices.io phrases the last criterion as “segregate by characteristics”: resource, availability and security requirements. It is one of the few technical arguments for a separate service; most of the others are organizational.
The main organizational argument is Conway's Law: an organization that designs a system produces a design that copies its communication structure. Fowler retells it like this: if a single team writes a compiler, it will be a one-pass compiler; if the team is divided into two, a two-pass compiler. Microservices let each team look after a relatively independent part of the system with infrequent and formal communication between teams. With a single team this argument does not apply: boundaries between one team's services are just extra network calls.
The cost of distribution
In MicroservicePremium (2015) Fowler calls the cost of microservices a premium: the complexity the style itself adds to a project — automated deployment, monitoring, dealing with failure, eventual consistency. His conclusion: do not consider microservices unless the system is too complex to manage as a monolith, and build most systems as a single monolith, paying attention to modularity within it.
Remote calls are slow
An in-process function call is almost never the bottleneck; the network can be. If a service calls six others and each of them calls six more, latencies add up. Coarser calls and asynchrony help, but asynchronous code is harder to write and debug.
Remote calls fail
An in-process call is expected to work; a network request can fail at any moment. For every call you have to decide what to do on failure: a timeout, a retry, degradation, compensation.
Eventual consistency
Each service owns its data, so a change that spans several services does not become visible at once. The user updates something, refreshes the page — and the change is not there yet.
Operational complexity
A few applications turn into dozens of services with their own releases. You need rapid provisioning, monitoring and automated deployment — Fowler calls them prerequisites before the move.
- A new environment comes up in hours, not weeks
- Monitoring catches both technical failures (errors, availability) and business ones (for example, a drop in orders)
- Every component deploys and rolls back automatically
- Logs from different components are linked by one request ID
- Developers take part in operating their services
The checklist retells what Fowler's MicroservicePrerequisites names as the minimum before the first service, plus log correlation, without which a distributed system is hard to debug. If half the items are missing, the premium will exceed the benefit. More on observability in the monitoring and logging lesson.
Data: the hardest boundary to draw
The Database per service pattern on microservices.io requires a service's data to be private and accessible only via its API, and a service's transactions to involve only its database. That does not need a server per service: the options are private tables, a schema per service or a server per service, and the first two are the cheapest. The pattern's author recommends barriers, such as a separate database user per service with grants only on its own objects — without a barrier developers will bypass the API.
The same trick works in a modular monolith: one PostgreSQL database, a schema per module and a role per module. There are no foreign keys between modules — a booking stores the service ID but validates it through the catalog API. This is a deliberate loss of referential integrity for the sake of module independence; a monolith can keep it, but then extracting the module into a service gets harder.
-- One database, one schema per module
CREATE SCHEMA catalog;
CREATE SCHEMA booking;
CREATE TABLE catalog.services (
id text PRIMARY KEY,
title text NOT NULL,
duration_min integer NOT NULL CHECK (duration_min > 0),
price_kzt integer NOT NULL,
archived boolean NOT NULL DEFAULT false
);
-- booking stores the service id, but catalog owns the row:
-- no cross-module foreign key, the id is checked through the catalog API
CREATE TABLE booking.bookings (
id uuid PRIMARY KEY,
service_id text NOT NULL,
master_id text NOT NULL,
client_phone text NOT NULL,
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL
);
-- each module's code connects with its own role and sees only its schema
CREATE ROLE booking_module NOLOGIN;
GRANT USAGE ON SCHEMA booking TO booking_module;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA booking TO booking_module;SET ROLE booking_module;
INSERT INTO booking.bookings VALUES
(gen_random_uuid(), 'haircut', 'm1', '+77011234567', '2026-10-12 10:00+00', '2026-10-12 11:00+00');
SELECT service_id, master_id FROM booking.bookings;
SELECT title FROM catalog.services;$ docker exec -i kb-pg-modulnyj-monolit psql -U postgres < check.sql
SET
INSERT 0 1
service_id | master_id
------------+-----------
haircut | m1
(1 row)
ERROR: permission denied for schema catalog
LINE 1: SELECT title FROM catalog.services;
^The price of splitting data shows up in operations that span several modules. An example from microservices.io: placing an order must check the customer's credit limit, while customer data and orders belong to different services. In a monolith that is one transaction; between services it is a saga with compensating actions. A query like “customers in a region and their recent orders” is one JOIN in a monolith; between services it is API composition or a separate read model (CQRS). The more such operations, the more expensive the split.
A modular monolith in code: public API and no back doors
A small verified example is an appointment booking service with three modules: the service catalog, booking and notifications. Each module is a folder in src/modules; its public API is the index.ts file, everything else lives in internal and is not available from outside.
src/modules/catalog/index.ts
- What is inside
CatalogApi,BookableService,createCatalogModule- Who may import it
- Any module
src/modules/catalog/internal/services-store.ts
- What is inside
- Service storage with price and archive flag
- Who may import it
- The catalog only
src/modules/booking/index.ts
- What is inside
book,BookingError, theBookingCreatedevent- Who may import it
- Any module
src/modules/booking/internal/schedule.ts
- What is inside
- Interval overlap, booking storage
- Who may import it
- The booking module only
src/modules/notifications/index.ts
- What is inside
- Subscription to
booking.created - Who may import it
- The composition root
src/shared/events.ts
- What is inside
- In-process event bus
- Who may import it
- Everyone, but it knows no modules
The catalog exposes only what booking needs: the title and the duration. The price and the archive flag stay inside — they can change without touching other modules.
import { type ServiceRow, ServicesStore } from "./internal/services-store.js";
// Public API of the catalog module: the only file other modules may import
export type BookableService = { id: string; title: string; durationMin: number };
export interface CatalogApi {
findBookableService(id: string): Promise<BookableService | null>;
}
export function createCatalogModule(seed: ServiceRow[] = []) {
const store = new ServicesStore();
seed.forEach((row) => store.add(row));
const api: CatalogApi = {
async findBookableService(id) {
const row = store.find(id);
if (!row || row.archived) return null;
// price and archive flag stay inside the module
return { id: row.id, title: row.title, durationMin: row.durationMin };
},
};
return { api, admin: { addService: (row: ServiceRow) => store.add(row) } };
}The booking module depends on the CatalogApi interface, not on the catalog's implementation, and announces a new booking with an event. Within one process a simple bus delivers the event; if the notifications module becomes a service, a message broker takes its place, and the transactional outbox pattern makes publishing reliable — it is covered in the lesson on events and queues.
import { randomUUID } from "node:crypto";
import type { InProcessEvents } from "../../shared/events.js";
import type { CatalogApi } from "../catalog/index.js";
import { BookingsStore, overlaps } from "./internal/schedule.js";
// Public API of the booking module: its input, its errors and its events
export type BookInput = { serviceId: string; masterId: string; clientPhone: string; startsAt: Date };
export type BookingCreated = {
type: "booking.created";
bookingId: string;
masterId: string;
clientPhone: string;
serviceTitle: string;
startsAt: string;
};
export class BookingError extends Error {
constructor(readonly code: "UNKNOWN_SERVICE" | "SLOT_TAKEN") {
super(code);
}
}
export function createBookingModule(deps: {
catalog: CatalogApi;
events: InProcessEvents<BookingCreated>;
}) {
const store = new BookingsStore();
return {
async book(input: BookInput): Promise<{ bookingId: string; endsAt: Date }> {
// Another module is reached only through its public API
const service = await deps.catalog.findBookableService(input.serviceId);
if (!service) throw new BookingError("UNKNOWN_SERVICE");
const endsAt = new Date(input.startsAt.getTime() + service.durationMin * 60_000);
const slot = { startsAt: input.startsAt, endsAt };
if (store.forMaster(input.masterId).some((b) => overlaps(b, slot))) {
throw new BookingError("SLOT_TAKEN");
}
const booking = { id: randomUUID(), ...input, endsAt };
store.add(booking);
await deps.events.publish({
type: "booking.created",
bookingId: booking.id,
masterId: booking.masterId,
clientPhone: booking.clientPhone,
serviceTitle: service.title,
startsAt: booking.startsAt.toISOString(),
});
return { bookingId: booking.id, endsAt };
},
};
}// In-process event bus: modules talk without importing each other's internals.
// If a module becomes a service, a message broker takes this place.
type Handler<E> = (event: E) => void | Promise<void>;
export class InProcessEvents<E extends { type: string }> {
private readonly handlers = new Map<string, Handler<E>[]>();
subscribe<T extends E["type"]>(type: T, handler: Handler<Extract<E, { type: T }>>): void {
const list = this.handlers.get(type) ?? [];
list.push(handler as Handler<E>);
this.handlers.set(type, list);
}
async publish(event: E): Promise<void> {
for (const handler of this.handlers.get(event.type) ?? []) {
await handler(event);
}
}
}type fieldimport type { InProcessEvents } from "../../shared/events.js";
import type { BookingCreated } from "../booking/index.js";
// Notifications know the booking event contract, not the booking tables
export function createNotificationsModule(deps: { events: InProcessEvents<BookingCreated> }) {
const outbox: string[] = [];
deps.events.subscribe("booking.created", (event) => {
outbox.push(`${event.clientPhone}: ${event.serviceTitle}, ${event.startsAt}`);
});
return { sent: () => [...outbox] };
}import { createBookingModule, type BookingCreated } from "./modules/booking/index.js";
import { createCatalogModule } from "./modules/catalog/index.js";
import { createNotificationsModule } from "./modules/notifications/index.js";
import { InProcessEvents } from "./shared/events.js";
// One process, one deployment: modules are wired together here
export function createApp() {
const events = new InProcessEvents<BookingCreated>();
const catalog = createCatalogModule([
{ id: "haircut", title: "Haircut", durationMin: 60, priceKzt: 8000, archived: false },
{ id: "beard", title: "Beard trim", durationMin: 30, priceKzt: 4000, archived: true },
]);
const booking = createBookingModule({ catalog: catalog.api, events });
const notifications = createNotificationsModule({ events });
return { catalog, booking, notifications };
}import { describe, expect, it } from "vitest";
import { createApp } from "../src/app.js";
import { BookingError } from "../src/modules/booking/index.js";
const at = (iso: string) => new Date(iso);
describe("modular monolith: booking through public APIs", () => {
it("books a slot and notifies through an in-process event", async () => {
const app = createApp();
const { endsAt } = await app.booking.book({
serviceId: "haircut",
masterId: "m1",
clientPhone: "+77011234567",
startsAt: at("2026-10-12T10:00:00Z"),
});
expect(endsAt).toEqual(at("2026-10-12T11:00:00Z"));
expect(app.notifications.sent()).toEqual([
"+77011234567: Haircut, 2026-10-12T10:00:00.000Z",
]);
});
it("rejects an overlapping slot for the same master", async () => {
const app = createApp();
const base = { serviceId: "haircut", masterId: "m1", clientPhone: "+77011234567" };
await app.booking.book({ ...base, startsAt: at("2026-10-12T10:00:00Z") });
await expect(
app.booking.book({ ...base, startsAt: at("2026-10-12T10:30:00Z") }),
).rejects.toEqual(new BookingError("SLOT_TAKEN"));
// back-to-back is fine: intervals are half-open
await app.booking.book({ ...base, startsAt: at("2026-10-12T11:00:00Z") });
expect(app.notifications.sent()).toHaveLength(2);
});
it("does not book archived or unknown services", async () => {
const app = createApp();
const input = { masterId: "m1", clientPhone: "+77011234567", startsAt: at("2026-10-12T10:00:00Z") };
await expect(app.booking.book({ ...input, serviceId: "beard" })).rejects.toMatchObject({
code: "UNKNOWN_SERVICE",
});
await expect(app.booking.book({ ...input, serviceId: "nope" })).rejects.toBeInstanceOf(BookingError);
});
});The compiler does not forbid importing ../catalog/internal/… from the booking module. dependency-cruiser does: a rule with a $1 group lets a module import its own files and other modules' index.ts, but not their internals, and no-circular requires module dependencies to form an acyclic graph.
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: "modules-only-via-public-api",
comment: "Another module is reachable only through its index.ts",
severity: "error",
from: { path: "^src/modules/([^/]+)/" },
to: {
path: "^src/modules/",
pathNot: ["^src/modules/$1/", "^src/modules/[^/]+/index\\.ts$"],
},
},
{
name: "shared-knows-no-modules",
comment: "shared is a toolbox, not a back door between modules",
severity: "error",
from: { path: "^src/shared/" },
to: { path: "^src/modules/" },
},
{
name: "no-circular",
comment: "Module dependencies must form a directed acyclic graph",
severity: "error",
from: {},
to: { circular: true },
},
{
name: "not-to-unresolvable",
severity: "error",
from: {},
to: { couldNotResolve: true },
},
],
options: {
doNotFollow: { path: "node_modules" },
tsPreCompilationDeps: true,
tsConfig: { fileName: "tsconfig.json" },
},
};shared knows no modules, no cycles$ npx tsc --noEmit && npx vitest run --reporter=verbose
✓ test/app.test.ts > modular monolith: booking through public APIs > books a slot and notifies through an in-process event 3ms
✓ test/app.test.ts > modular monolith: booking through public APIs > rejects an overlapping slot for the same master 1ms
✓ test/app.test.ts > modular monolith: booking through public APIs > does not book archived or unknown services 1ms
Test Files 1 passed (1)
Tests 3 passed (3)
$ npx depcruise src --config .dependency-cruiser.cjs
✔ no dependency violations found (8 modules, 11 dependencies cruised)Now two typical violations: the booking module borrows the price type from the catalog's internals, and the catalog starts importing the booking module to count popular services.
--- a/src/modules/booking/index.ts
+++ b/src/modules/booking/index.ts
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import type { InProcessEvents } from "../../shared/events.js";
import type { CatalogApi } from "../catalog/index.js";
+import type { ServiceRow } from "../catalog/internal/services-store.js";
import { BookingsStore, overlaps } from "./internal/schedule.js";
// Public API of the booking module: its input, its errors and its events
@@ -12,6 +13,7 @@ export type BookingCreated = {
clientPhone: string;
serviceTitle: string;
startsAt: string;
+ priceKzt?: ServiceRow["priceKzt"];
};
export class BookingError extends Error {
--- a/src/modules/catalog/index.ts
+++ b/src/modules/catalog/index.ts
@@ -1,5 +1,9 @@
+import type { BookingCreated } from "../booking/index.js";
import { type ServiceRow, ServicesStore } from "./internal/services-store.js";
+// catalog wants to count popular services from booking events
+export type PopularityInput = Pick<BookingCreated, "serviceTitle">;
+
// Public API of the catalog module: the only file other modules may import
export type BookableService = { id: string; title: string; durationMin: number };$ npx tsc --noEmit && npx vitest run
Tests 3 passed (3)
$ npx depcruise src --config .dependency-cruiser.cjs
error no-circular: src/modules/booking/index.ts →
src/modules/catalog/index.ts →
src/modules/booking/index.ts
error modules-only-via-public-api: src/modules/booking/index.ts → src/modules/catalog/internal/services-store.ts
x 2 dependency violations (2 errors, 0 warnings). 8 modules, 13 dependencies cruised.The proper fixes: if booking needs the price, the catalog adds it to the public BookableService; if the catalog needs statistics, it subscribes to the booking.created event in the composition root instead of importing the booking module. In Java, Spring Modulith gives the same check: calling ApplicationModules.of(Application.class).verify() rejects cycles between modules and references to their internal packages.
The evolution path: monolith first, then the strangler fig
In MonolithFirst (2015) Fowler writes that almost all successful microservice stories started with a monolith that got too big and was broken up, while almost all systems built as microservices from scratch ended up in serious trouble. Two arguments: at the start you do not know whether users need the product, and speed matters more than scalability; and the right boundaries between services are hard to get right even for experienced architects in familiar domains — in a monolith they are cheaper to find and move. Fowler also gives the counter-argument: building a sufficiently modular monolith takes discipline, and an early split gets teams used to working independently.
When extraction is due, Fowler advises against rewriting the system wholesale and in favour of replacing it gradually — the Strangler Fig approach. The name comes from the strangler fig that grows on a host tree and eventually replaces it. New features and extracted parts are built next to the old system, behaviour moves over piece by piece, and the temporary code that lets old and new coexist (transitional architecture) is removed at the end. Fowler names four activities: understand the outcomes you want, decide how to break the problem into parts, successfully deliver the parts, and change the organization so this happens on an ongoing basis.
A modular monolith shortens this path: the module's public API is already a contract, the data is already split by schemas, communication already goes through events. Extraction becomes replacing a function call with a network client and the in-process bus with a broker, not digging through shared code. Fowler also mentions an intermediate route — start with a few coarse-grained services and split them once boundaries settle.
Service catalog
- Owns data
- Services, prices, durations
- Candidate for a service?
- No: few rules, read by everyone, changes together with booking
Booking
- Owns data
- Bookings, master schedules
- Candidate for a service?
- Not while there is one team: it is the core and needs transactions
Notifications
- Owns data
- Templates, delivery log
- Candidate for a service?
- First candidate: asynchronous, external providers, its own load profile
Payments
- Owns data
- Payments, refunds
- Candidate for a service?
- Possibly: different security and audit requirements
Reports
- Owns data
- Read model
- Candidate for a service?
- Possibly: heavy queries must not slow booking down
The table is a teaching map of modules, not a universal answer: the same notifications module can live in the monolith for years if there are few deliveries and one team.
Decision table
A new product, the hypothesis is untested
- What to choose
- Modular monolith
- Why
- Speed of change matters more than scale; boundaries will still move
One team of up to 8–10 people
- What to choose
- Modular monolith
- Why
- Conway's argument does not apply, the distribution premium does not pay off
Several teams get in each other's way when releasing
- What to choose
- A modular monolith with strict boundaries, then extracting one service at a time
- Why
- First check that deployment is the problem, not code coupling
Part of the system needs different load, availability or protection
- What to choose
- Extract that part into a service
- Why
- Segregating by characteristics is a technical argument for a service
A legacy monolith without boundaries that has to move
- What to choose
- Strangler fig: new features alongside, migration piece by piece
- Why
- A full replacement delivers no value for a long time and rarely keeps up with the business
No automated deploy, monitoring or tracing
- What to choose
- Do not start with microservices
- Why
- The prerequisites are missing; the premium will exceed the benefit
The “8–10 people” threshold is our rule of thumb from experience, not a rule from the sources: what matters is not headcount but how many teams independently ship changes into one codebase.
Next: the clean architecture lesson shows how to arrange layers inside one module, the lesson on events and queues shows how to deliver events reliably between modules and services, and the system design walkthrough applies these decisions to a concrete problem end to end.
How this was verified4 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.