A team of four starts a new product. Before the first customer signs up, they have seven services, a message broker, a Kubernetes cluster, and a shared document explaining which service owns what. Nobody can run the whole thing on their laptop.
Six months later, the product has changed direction twice. Every change touches three services. Every bug report starts with “which service logged this?”
This pattern is common enough that my default advice is now simple: start with a modular monolith. One deployable application, split inside into modules with hard boundaries. You get most of the design benefits people want from microservices, and almost none of the operating cost.
This post covers what microservices really cost a small team, what a modular monolith looks like in TypeScript, how to enforce its boundaries with tools instead of good intentions, and how to tell when a module has earned the right to become its own service.
What microservices actually cost a small team
Microservices are not bad. They solve real problems for large organizations. But the problems they solve are mostly people problems at scale: many teams who need to ship independently. If you’re one team of three to eight developers, you pay the costs without getting that benefit.
Here’s the bill.
Function calls become network calls
Inside one process, billing.createInvoice(...) either returns or throws. Across a network, it can also be slow, time out, succeed on the other side while you see a timeout, or fail halfway. Every call now needs timeouts, retries, and a decision about what happens when the other service is down.
I wrote a whole post about this kind of defensive plumbing in Integrating Airbnb, Booking.com and WhatsApp. That work is necessary when the other side is someone else’s API. It’s a strange thing to volunteer for between two parts of your own app.
Transactions stop being simple
In a monolith with one database, “confirm the booking and create the invoice” can be one database transaction. Either both happen or neither does.
Split bookings and billing into two services with two databases, and you’ve lost that. Now you need a distributed transaction (a change that must succeed or fail together across several systems), which in practice means patterns like sagas, outboxes and compensating actions. These are good patterns. They are also a lot of code to write before you have customers.
Deployment and observability multiply
One app means one build, one deploy pipeline, one set of logs, one dashboard. Seven services mean seven of each, plus versioning between them, plus distributed tracing (following one request as it hops across services) just to answer “why was this request slow?”
Local development hurts
Running a feature locally now means starting several services, a broker and several databases, often through a large Docker Compose file that someone has to maintain. New developers lose days to setup. Debugging means jumping between terminals instead of stepping through one call stack.
Your data is split before you know its shape
Each service owns its own database. That’s correct for microservices. But early on, you don’t yet know which data belongs together. A report that needs bookings and invoices was one SQL query. Now it’s two API calls and a join in application code, or a separate reporting pipeline.
Martin Fowler made a related point back in 2015 in his “MonolithFirst” article. In short: the successful microservice systems he saw mostly started as monoliths that were split later, and systems built as microservices from day one often ran into serious trouble. A big reason is that good service boundaries are hard to draw before you understand the domain, and moving logic between services is far harder than moving it between modules. He’s careful to call the evidence limited, which is fair. But it matches what I’ve seen.
What a modular monolith is
A modular monolith is one application, built and deployed as one unit, whose code is divided into modules with strict rules about how they talk to each other.
Four rules define it:
- One deployable. One process (or several identical copies behind a load balancer), one build, one deploy.
- Clear modules. Each module covers one business area: bookings, billing, notifications, users.
- Public interface, private internals. Each module exposes a small set of functions and types. Everything else inside it is private. Other modules may only use the public part.
- Each module owns its own tables. Only the billing module reads and writes billing tables. Nobody else queries them directly.
If that sounds like microservices without the network, that’s exactly the idea. The boundaries are the same kind of boundaries. The difference is that crossing one costs a function call, not a network round trip.
A TypeScript example: bookings and billing
Let’s make it concrete. A rental booking app has a bookings module and a billing module. When a booking is confirmed, an invoice should be created.
Folder structure:
src/
modules/
bookings/
index.ts <- public API
bookings.service.ts
bookings.repo.ts
bookings.types.ts
billing/
index.ts <- public API
billing.service.ts
billing.repo.ts
billing.handlers.ts
platform/
events.ts <- in-process event bus
db.ts
main.ts
The public interface
Each module’s index.ts is its front door. It exports only what other modules are allowed to use:
// src/modules/billing/index.ts
export { createInvoice, getInvoiceStatus } from "./billing.service";
export { registerBillingHandlers } from "./billing.handlers";
export type { InvoiceStatus } from "./billing.service";
The repo, the SQL, the helper functions, the internal types: none of that is exported. If another module needs something that isn’t here, that’s a conversation, not a quick import.
A small in-process event bus
Modules can talk two ways. A direct call when the caller needs an answer now (“what’s the invoice status for this booking?”). An event when the caller just announces that something happened and doesn’t care who reacts (“a booking was confirmed”).
Here’s a minimal typed event bus. No library, just TypeScript:
// src/platform/events.ts
type Events = {
"booking.confirmed": { bookingId: string; guestId: string; totalCents: number };
"booking.cancelled": { bookingId: string };
};
type Handler<K extends keyof Events> = (payload: Events[K]) => Promise<void>;
const handlers: { [K in keyof Events]: Handler<K>[] } = {
"booking.confirmed": [],
"booking.cancelled": [],
};
export function on<K extends keyof Events>(name: K, handler: Handler<K>) {
handlers[name].push(handler);
}
export async function emit<K extends keyof Events>(name: K, payload: Events[K]) {
for (const handler of handlers[name]) {
await handler(payload);
}
}
The Events type is the shared contract. Payloads carry plain IDs and values, never another module’s internal objects.
Bookings emits, billing reacts
The bookings module confirms a booking and announces it. It has no idea billing exists:
// src/modules/bookings/bookings.service.ts
import { emit } from "../../platform/events";
import * as repo from "./bookings.repo";
export async function confirmBooking(bookingId: string): Promise<void> {
const booking = await repo.findById(bookingId);
if (!booking) throw new Error(`Booking ${bookingId} not found`);
if (booking.status === "confirmed") return; // safe to call twice
await repo.updateStatus(bookingId, "confirmed");
await emit("booking.confirmed", {
bookingId,
guestId: booking.guestId,
totalCents: booking.totalCents,
});
}
Billing subscribes at startup:
// src/modules/billing/billing.handlers.ts
import { on } from "../../platform/events";
import { createInvoice } from "./billing.service";
export function registerBillingHandlers() {
on("booking.confirmed", async (e) => {
await createInvoice({ bookingId: e.bookingId, amountCents: e.totalCents });
});
}
And main.ts wires it together:
// src/main.ts
import { registerBillingHandlers } from "./modules/billing";
registerBillingHandlers();
// ...start the HTTP server
Tomorrow, when you add a notifications module that emails the guest on confirmation, you add one more handler. The bookings code doesn’t change. That’s the Open/Closed idea from Writing Better Code with SOLID Principles, applied at the module level.
One honest warning: this bus is in memory. If the process crashes after the booking is saved but before the invoice is created, the event is gone. For things that must not be lost, like invoices, write the event to an outbox table in the same transaction as the booking change, and process it with a background worker. I cover that pattern in Background Jobs Done Right. The module boundaries stay exactly the same either way, which is the point.
Before and after: the cross-module join
The most common boundary leak isn’t an import. It’s SQL.
Before, the bookings repo reaches straight into billing’s table:
// src/modules/bookings/bookings.repo.ts (before)
export async function findWithInvoiceStatus(id: string) {
const { rows } = await db.query(
`SELECT b.*, i.status AS invoice_status
FROM bookings b
JOIN invoices i ON i.booking_id = b.id
WHERE b.id = $1`,
[id],
);
return rows[0];
}
It works, and it’s convenient. It also means billing can never rename a column, change how invoices are stored, or move to its own database without breaking bookings. The two modules are welded together through the database, and no import graph will show it.
After, each module reads its own data, and whoever needs both combines them:
// src/http/booking-details.route.ts (after)
import { getBooking } from "../modules/bookings";
import { getInvoiceStatus } from "../modules/billing";
export async function bookingDetails(id: string) {
const [booking, invoiceStatus] = await Promise.all([
getBooking(id),
getInvoiceStatus(id),
]);
return { ...booking, invoiceStatus };
}
Yes, that’s two queries instead of one. For almost every screen in a young product, the difference is too small to notice. When it isn’t, the right fix is usually a read model that billing keeps up to date by listening to events, not a join.
Enforce the boundaries with tools, not trust
Folder structure alone doesn’t hold. On a deadline, someone will import ../billing/billing.repo because it’s right there. Then someone else copies that import. Within a few months, the “modules” are just folders.
So make the rules machine-checked, and run them in CI.
Rule 1: Other modules import only from index.ts
With ESLint’s built-in no-restricted-imports rule, you can block deep imports into another module. If you use a path alias like @/modules/... for cross-module imports, this flat-config rule rejects anything that goes past a module’s root:
// eslint.config.js
export default [
{
files: ["src/**/*.ts"],
rules: {
"no-restricted-imports": ["error", {
patterns: [{
group: ["@/modules/*/*"],
message: "Import other modules through their index.ts only.",
}],
}],
},
},
];
So import { createInvoice } from "@/modules/billing" passes, and from "@/modules/billing/billing.repo" fails. Inside a module, use relative imports for its own files.
The weakness: this checks the import string, so a relative path like ../billing/billing.repo slips past. That’s why I like adding dependency-cruiser, which checks the actual resolved files. This rule says: a file in one module may not depend on another module’s files, except its index.ts:
// .dependency-cruiser.js
module.exports = {
forbidden: [
{
name: "modules-only-via-index",
comment: "Other modules may only import a module's index.ts",
severity: "error",
from: { path: "^src/modules/([^/]+)/" },
to: {
path: "^src/modules/[^/]+/",
pathNot: ["^src/modules/$1/", "^src/modules/[^/]+/index\\.ts$"],
},
},
],
};
The $1 refers to the module name captured in from, so imports inside the same module are allowed. Run it with npx dependency-cruiser src (recent versions find the config file on their own; older ones need --config). Check the docs for the version you install, since option details do change.
Rule 2: Modules don’t share tables
This one is harder to lint, so make it structural:
- One Postgres schema per module.
bookings.reservations,billing.invoices. A query that touches two schemas stands out immediately in code review. - Only the module’s repo files contain SQL for its schema. Search for
billing.in any file outsidesrc/modules/billing/and fail the build if you find it. A ten-line script does this. - No foreign keys across modules. Store the
booking_idin billing as a plain value. A cross-module foreign key is a join waiting to happen, and it would block you from ever splitting the database.
Rule 3: Communicate by call or event, nothing else
No shared global state, no reaching into another module’s cache, no “temporary” exported internal helper. If it isn’t in index.ts or the Events type, it isn’t part of the contract.
This is the same dependency discipline as in Clean Architecture in Practice, turned sideways. Clean Architecture controls dependencies between layers. Modules control dependencies between business areas. You can, and usually should, do both: each module can have its own small domain, application and infrastructure layers inside it.
When you actually need a service
A modular monolith isn’t forever. Sometimes a module really should run on its own. These are the signals I take seriously:
- A different scaling profile. One module needs very different resources from the rest. For example, a module that renders PDFs or runs heavy AI processing eats CPU and memory in bursts, while the rest of the app serves light HTTP requests. Scaling the whole monolith to fit that one module is wasteful.
- A different team. A separate team owns the module, and coordinating releases through one codebase is slowing both teams down. This is the original reason microservices exist.
- A different release cadence. One area changes daily, another must change rarely and carefully, and shipping them together creates risk or friction.
- A hard isolation need. Security, compliance or reliability says this code must be separate. Payment card handling, code that runs untrusted input, or a part that must stay up even when the main app is down.
Notice what’s not on the list: “the codebase feels big,” “it’s more modern,” or “we might need to scale someday.” A big codebase with good boundaries is fine. A big codebase with bad boundaries will become a distributed big ball of mud, which is worse.
Why clean modules make extraction easy
If you followed the rules above, extracting the billing module is mostly mechanical:
- The interface already exists.
billing/index.tsbecomes the API of the new service. Each exported function becomes an endpoint or RPC. - The data already lives alone. Billing’s tables sit in their own schema with no foreign keys pointing in. Move the schema to a new database.
- Events already exist. Swap the in-process
emitfor a real broker or queue for those event names. Handlers keep the same shape. - Callers barely change. Replace
import { getInvoiceStatus } from "@/modules/billing"with a small client that has the same function signature and calls over HTTP.
The hard part of microservices is drawing the boundaries correctly. A modular monolith lets you draw them, live with them, and fix them cheaply while you learn. By the time you extract a service, the boundary has been tested by real use.
Common mistakes
The “shared” module that eats everything
Every modular codebase grows a shared/ or utils/ folder. It starts with a date formatter. Then a money helper. Then “just the user type, everyone needs it.” Then a function that loads a booking with its invoice, because two modules needed it.
A year later, every module depends on shared, and shared depends on half the business logic. You have one module again, with extra folders.
Rules that help:
sharedmay contain only things with no business meaning: logging, config, the database client, the event bus, generic helpers likesleeporchunk.sharednever imports from a module. Add this to your dependency-cruiser rules too.- If something has a business name, it has a business home. A
Moneytype might be shared. AcalculateCleaningFeefunction belongs to a module.
Modules that share tables
This is the quiet one. The import graph looks perfect, the lint rules pass, and three modules all run UPDATE bookings SET .... Now no single module owns the rules about what a valid booking is, and any of the three can break the others.
If two modules both need to change the same table, either the table belongs to one of them and the other should call it, or your module split is wrong and they should be one module. Both are fine answers. Shared ownership isn’t.
Modules too small
Ten modules for a product with three real business areas means every feature crosses boundaries, and you spend your time writing interfaces. Start with a few coarse modules. Splitting a module later is easy. Merging two that were split badly is annoying.
Boundaries without enforcement
Folders and a team agreement last about one deadline. If the rules aren’t in CI, they aren’t rules.
Takeaway
A checklist for your next backend:
- One deployable, split into a few modules named after business areas.
- Every module has an
index.tsthat exports its public functions and types. Everything else is private. - Every module owns its tables, ideally in its own Postgres schema, with no cross-module joins or foreign keys.
- Modules talk by function call or event, through the public API only.
- Boundaries are checked in CI with
no-restricted-imports, dependency-cruiser, or both. sharedholds plumbing only, never business logic.- Extract a service only for a real reason: a different scaling profile, team, release cadence, or a hard isolation need.
Microservices are a tool for organizations that have outgrown one codebase. Most small teams haven’t, and won’t for a long time. Build the boundaries first. The network can wait.
Sources: Martin Fowler, “MonolithFirst” (2015); ESLint docs for no-restricted-imports; dependency-cruiser README and rules reference.
Related: Clean Architecture in Practice, Writing Better Code with SOLID Principles, and Background Jobs Done Right.



