You are about to decide how a product is structured, and a wrong call costs a year of engineering. The monolith vs microservices question usually shows up with a fashion attached: a conference talk, a hire from a much larger company, a fear of rewriting later. It rarely shows up with data about your team, your domain or your deploy pain.
This article is a decision framework, not a glossary. We define the three options, including the modular monolith that sits between them, then score each by team size, product stage, deployment frequency and scaling need. After that we price the costs architecture diagrams leave out, show when a Next.js full-stack app is enough, and lay out a safe migration path.
Our default answer is short. Start with a modular monolith, enforce its boundaries in code, and split a module out only when a measured pain justifies a network boundary. The rest of the article shows how to measure that pain.
Key takeaways
- Start with a modular monolith: one deploy unit with enforced module boundaries gives most teams the ownership benefits of microservices without the network.
- Split on measured team and scaling pain, such as blocked deploys or a module with a different load profile, never on fashion or codebase size.
- Microservices cost observability, data consistency and DevOps effort, and every network call replaces a function call with latency and partial failure.
- Migrate with the strangler pattern: put a proxy in front, extract one seam at a time, run both versions and delete the old code.
Monolith vs microservices defined, with the middle path
The monolith vs microservices debate stalls when each side means something different by the words. So here is the shared vocabulary, in definitions you can quote in a design review.
What a monolith is
A monolith is an application built and deployed as one unit: one codebase, one build artifact and, usually, one database. A call from the billing logic to the order logic is a function call inside one process. It either returns or throws, and it shares a transaction with its caller.
Monolith does not mean messy. Deployment shape and internal structure are separate decisions, and most arguments against monoliths are really arguments against unstructured ones.
What microservices are
Martin Fowler and James Lewis describe microservices as a suite of small services, each running in its own process and talking through lightweight mechanisms, often an HTTP API. The services are built around business capabilities and deploy independently. The word that carries the weight is independently.
If two services must always ship together, you have a distributed monolith: the costs of both styles and the benefits of neither. Each real microservice also owns its data. There are no shared tables and no cross-service joins.
The modular monolith in between
A modular monolith deploys as one unit but is organized into modules with explicit boundaries. Each module exposes a small public API and hides its internals. Other modules never reach into its private files or its tables.
This gets you most of what teams want from microservices: clear ownership, local reasoning and parts you can test alone. It skips the network. It also keeps a door open, because a module with a clean boundary is a service you can extract later.
Option A
Monolith
- Deploy unit: one artifact
- Data: one shared database, any code can read any table
- Team fit: one small team
Our pick for most teams
Modular monolith
- Deploy unit: one artifact
- Data: each module owns its tables by rule
- Team fit: one team up to a few squads
Option C
Microservices
- Deploy unit: many, deployed independently
- Data: each service owns its datastore
- Team fit: several autonomous teams
Fowler's Monolith First argues for this default. In his account, the successful microservice systems he knew of began as monoliths that grew too big and were then split, while systems designed as microservices from day one often ran into serious trouble. His reasoning is that you cannot draw good service boundaries before you know the domain, and a wrong boundary across a network is expensive to move.
Monolith vs microservices decision table
Use the table below as a first filter. The ranges are rules of thumb from our projects and common industry practice, not measured cutoffs, so read them as directions rather than lines.
| Factor | Single monolith | Modular monolith | Microservices |
|---|---|---|---|
| Team size | One team of up to about five engineers | One to a few squads, roughly up to 25 engineers | Several autonomous teams, each owning services end to end |
| Product stage | Prototype, domain still shifting | Product in market, boundaries emerging | Stable domain, boundaries proven in code |
| Deployment frequency | Everything ships together and that is fine | Ships together, tested per module | Teams must ship independently and often |
| Scaling need | Uniform load, replicas are enough | Uniform load with one or two hot paths | Modules with very different load or runtime needs |
| Recommended default | Fine for a prototype, risky if left unstructured | Default for most B2B products and stores | Earned by a measured pain, never assumed |
Read the table by pain, not by fashion. If you cannot name a row where microservices clearly win for you today, the default holds. The factors below explain how to read each row.
Team size and ownership
Microservices solve a coordination problem between teams, not a code problem. When forty engineers share one deploy pipeline, they block each other, and service boundaries let teams ship alone. When six engineers share it, the coordination problem barely exists, and you pay the overhead for nothing.
A useful test is ownership. Can one team fully own a service, including its on-call rotation, its dashboards and its data? If not, the split creates orphaned services, which are worse than a shared module.
Product stage and uncertainty
Early products change shape every few weeks. Entities merge, flows disappear, a new customer segment rewrites the data model. In a monolith a cross-cutting change is one pull request and one deploy. Across services it is several coordinated releases, versioned contracts and a data migration.
This is why microservices vs monolith for startups is rarely a close call. A startup's scarce resource is learning speed, and a distributed system taxes every experiment. Once the domain stabilizes and boundaries are visible in the code, the question reopens.
Deployment frequency and scaling needs
These are two separate triggers. Deployment frequency matters when one module must ship many times a day while another changes quarterly under strict review, and coupling their releases hurts both. Scaling matters when one module, such as image processing, search indexing or report generation, needs far more CPU than the rest.
Before you split for scale, try the cheaper options. Run more replicas of the monolith, move heavy work to a queue and a worker process built from the same codebase, add a cache or a read replica. A worker with a different entrypoint is not a microservice, and it often solves the problem. For the infrastructure side, see our guide to cloud scalability on AWS, GCP and Azure.
Worked example: a B2B ordering portal
Take an illustrative case: seven engineers build a B2B ordering portal with custom price lists and an ERP sync. The team is one squad, the domain is still moving, releases go out weekly and the only heavy job is the nightly ERP sync. Reading the table, team size says monolith or modular, stage says modular, deployment says ship together, and scaling says a worker. The answer is a modular monolith with the ERP sync running as a queue worker from the same codebase.
The hidden costs of microservices
Microservices diagrams show boxes and arrows. They do not show the engineering hours behind each arrow. Three costs show up on almost every project that adopts the style, and teams tend to underestimate all three.
Why it matters
A function call does not time out, return a partial result or run an older version than its caller. A network call can do all three. Latency, partial failure and versioning become your problem at every boundary you add.
Versioning deserves a closer look. Once service A calls service B, B cannot change a response without a plan. You keep old fields alive, run two versions during a rollout and write contract tests for consumers. In a monolith the compiler finds the broken caller for you.
Observability and debugging across services
In a monolith, a stack trace tells you what happened. Across services, one user request touches several processes, and the trace exists only if every hop propagates a correlation ID. You need distributed tracing (OpenTelemetry is the common standard), structured logs that share a request ID, per-service metrics, and alerts that point at the failing hop instead of the symptom at the edge.
Without this, the first production incident turns into a search through five log streams. The cost is not the tooling license. It is the discipline of instrumenting every service the same way.
Request across services
- 01RequestOne user action enters the system
- 02GatewayStarts the trace ID
- AOrders serviceNetwork hop, trace ID passed on
- Billing serviceHop can time out
- BBilling serviceNetwork hop, trace ID passed on
- TimeoutTrace ID locates the failing hop
- CInventory serviceNetwork hop, trace ID passed on
A request in a monolith stays in one process, while in microservices it crosses network hops where a timeout needs tracing to locate
Data consistency without shared transactions
A monolith with one database gives you ACID transactions. Charge the card, reserve stock and create the invoice either all commit or none do. Split those steps into services with their own databases and that guarantee is gone.
You replace it with sagas, outbox tables, retries and compensating actions. Each is a pattern you must design, test and operate. Eventual consistency also leaks into the product: a customer sees an order with no invoice for a few seconds, and support has to know why. Reporting changes too, because a join across services becomes a data pipeline.
DevOps and platform overhead
Every service needs its own pipeline, container image, configuration, secrets, health checks, scaling rules, dashboards and on-call coverage. Local development gets heavier, because running ten services on a laptop is not the same as running one. Contract tests and production-like staging environments become mandatory instead of optional.
Here is an illustrative example. A team of eight engineers splits a product into twelve services. They now maintain twelve pipelines, twelve alert sets and twelve upgrade paths for base images, with the same eight people. Teams in this position often end up dedicating engineers to a platform function, and that cost is real even though it never appears on the diagram.
When a Next.js monolith is enough
Abstract advice is easy. Here is the concrete case for the stack we build most often, a Next.js application, and the point where it stops being enough.
What one Next.js app can carry
Next.js is a full-stack framework. Server components render pages, route handlers expose HTTP endpoints, server actions handle mutations and middleware runs before a request reaches a route. The Next.js documentation covers each of these. Behind them sit a database client, authentication, scheduled jobs and integration code.
For a B2B portal, a store with custom pricing or a SaaS dashboard, one Next.js app typically carries the interface, the API and the business logic in one deploy. Rendering and performance choices still matter inside that app, and we cover them in our post on ultra-fast websites with Next.js, Astro and Remix.
Where it stops: jobs that outlive a request, heavy CPU work, and workloads in another language. Those move to a worker process, still in the same repository, long before they justify a separate service.
Keeping module boundaries in code
The monolith stays healthy when boundaries are enforced by tooling, not goodwill. Keep routes thin: they parse input, call a module and render. Put business logic and data access in modules, and let other modules import only from a module's index.ts.
src/
app/ # routes: parse input, call modules, render
modules/
billing/
index.ts # public API, the only importable file
create-invoice.ts
internal/
invoice-repository.ts
orders/
index.ts
place-order.ts
The billing module hides its repository behind a public API:
// src/modules/billing/internal/invoice-repository.ts
export type Invoice = {
id: string;
orderId: string;
amountCents: number;
status: "open" | "paid";
};
// In-memory store for the example. Replace it with your database client.
const invoices = new Map<string, Invoice>();
export async function saveInvoice(invoice: Invoice): Promise<Invoice> {
invoices.set(invoice.id, invoice);
return invoice;
}
// src/modules/billing/create-invoice.ts
import { randomUUID } from "node:crypto";
import { saveInvoice, type Invoice } from "./internal/invoice-repository";
export async function createInvoice(orderId: string, amountCents: number): Promise<Invoice> {
return saveInvoice({ id: randomUUID(), orderId, amountCents, status: "open" });
}
// src/modules/billing/index.ts
// Public API of the billing module. Nothing else is importable from outside.
export { createInvoice } from "./create-invoice";
export type { Invoice } from "./internal/invoice-repository";
The orders module calls the public API and nothing else:
// src/modules/orders/place-order.ts
import { createInvoice } from "@/modules/billing"; // allowed: public API
export async function placeOrder(orderId: string, totalCents: number) {
const invoice = await createInvoice(orderId, totalCents);
return { orderId, invoiceId: invoice.id };
}
Then make the lint step enforce it. This ESLint flat config entry blocks any import that reaches past a module root. Add it to your existing flat config array.
// eslint.config.mjs: add this object to your existing flat config array
{
files: ["src/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": ["error", {
patterns: [{
group: ["@/modules/*/*"],
message: "Import from the module root (index.ts) only."
}]
}]
}
}
Apply the same rule to data. Billing owns its tables, and other modules call createInvoice instead of reading the invoices table. When you later extract billing, its public API becomes the service contract, and the lint rule has already told you every caller.
Signals that say it is time to split
These are the signals we look for. One alone is rarely enough. Two or three together, with a boundary you can already name, justify a split.
- Another team's release schedule repeatedly blocks your independent deploys
- One module needs a very different scaling profile, and replicas plus a worker did not fix it
- A module sits in its own compliance boundary, such as payment card data or health records
- The team has outgrown what a few small squads can coordinate in one codebase
- A module needs a different runtime or language, such as a Python model service
- You can draw the boundary and name its public API today
Notice what is missing from the list: the codebase feels big, or a new hire prefers microservices. Size is a modularity problem, and boundaries fix it. A network does not.
Migrating with the strangler pattern
If the signals are there, do not rewrite. Fowler's strangler pattern replaces a system piece by piece: new code grows around the old system until the old one can be removed. The system keeps serving customers the whole time.
Map modules
List modules, their callers and the tables each one touches. A dependency you cannot draw is a seam you cannot cut yet.
Pick the seam
Choose one module with a clean public API, its own data and a concrete reason to separate.
Put a proxy in front
Route all traffic through a reverse proxy or gateway that still points to the monolith.
Extract and run both
Build the service, copy its data and shift traffic gradually while comparing responses.
Remove old code
Delete the module from the monolith and drop its tables after a retention window.
Picking the first service to extract
The first extraction teaches your team the whole workflow, so choose a seam that is valuable and low risk. Good candidates have few inbound dependencies, a clear public API, their own data and a concrete reason to be separate, such as scale, compliance or release cadence.
Notification sending, PDF generation, search indexing, media processing and ERP sync are typical first services. Poor candidates are the entities every module reads, such as users or orders, and anything that needs a transaction across other modules.
Routing traffic and removing old code
Put a reverse proxy or gateway in front of the monolith before you change anything. It starts by sending all traffic to the monolith, then shifts one route at a time to the new service, first a small share, then all of it. Rollback is a configuration change, not a deploy.
Strangler pattern
- 01All routes to monolithProxy sends Modules A, B, C to one app
- 02Route A shiftsService A live, monolith keeps B and C
- 03Route B shiftsService B live, monolith keeps only C
- 04Monolith shrunkServices A and B, small monolith with C
The strangler pattern: a proxy first sends all routes to the monolith, then shifts them one by one to new services as the monolith shrinks
Data is the hard part. The new service gets its own datastore, filled by a one-time copy and kept in sync until you cut over reads and writes together. Then comes the step teams skip: delete the old module and, after a retention window, its tables. A strangled monolith that keeps dead code is harder to read than before you started.
ERP and CRM integration constraints
Integrations shape architecture more than preference does. ERP and CRM APIs are often rate limited, slow and batch oriented, and someone else owns their uptime and their schema. Whether you run a monolith or services, you must isolate them. Our post on API and legacy system integrations for enterprise websites goes deeper on the legacy side.
Integration as its own module
Treat each ERP or CRM as one module with an interface in your domain terms, such as syncCustomer or getStockLevel, never in vendor field names. The module owns authentication and token refresh, rate-limit handling, mapping between your model and the vendor's, caching of slow reads and an audit log. The rest of the app never imports the vendor SDK.
This pays twice. When the vendor changes its API, one module changes. When you split later, this module is often the first service, because it already has its own failure and scaling profile.
Webhooks, queues and idempotency
Webhooks arrive at least once, out of order and in bursts. Verify the signature, store the raw event, answer with a 2xx status fast and process in a worker. Use the event ID as an idempotency key so a replay does not create a duplicate order or a second invoice.
Outbound calls to a rate-limited ERP need a queue with controlled concurrency, retries with exponential backoff and jitter, and a dead-letter queue for events that keep failing. Cache what changes slowly, such as price lists and stock levels, and show the cache age in your admin so support knows what the customer saw.
Serverless, edge and headless in short
Engineering leaders often list these next to the monolith vs microservices choice. They are not alternatives to it. They answer different questions, where code runs and how the frontend is delivered, and a single monolith can be serverless, edge rendered and headless at the same time.
Where code runs
Serverless
Functions that run on demand and are billed per execution. Trade-off: cold starts, execution time limits and database connection management.
Where code runs
Edge runtimes
Code that runs at points of presence close to the visitor. Trade-off: a restricted runtime API and data that may sit far from the edge.
How the frontend ships
Headless and decoupled
Content or commerce is served by API to a separate frontend. Trade-off: two systems to run, plus preview and content modelling work.
Choose them by the problem in front of you. Serverless suits spiky, short work such as webhook handlers. Edge suits personalization and redirects that must run near the visitor. Headless suits teams where content editors and developers need to move independently, and we compare the tools in our guide to headless CMS and decoupled architecture.
None of them removes the need for module boundaries. A serverless deployment of a tangled app is still a tangled app.
Where to start
You can start this week without a rewrite or a reorganization. Do three things in order.
- Audit module boundaries. List your three largest areas, who imports what, and where circular dependencies live. A boundary you cannot draw is not ready to extract.
- Measure deploy pain. Track lead time from merge to production, how often releases block each other and how many rollbacks you run per month. Numbers beat opinions in an architecture meeting.
- Name the pain a split would fix. If you cannot, add the lint rule above and keep shipping from one codebase.
If you want a second opinion, we run architecture reviews for B2B teams building on Next.js and custom applications. Our solutions page shows how we build and run them, and you can contact us to book a review of your current structure.
Frequently asked questions
What is the difference between monolith and microservices?
A monolith is built and deployed as one unit with one codebase and usually one database. Microservices split the product into small services that deploy independently and own their own data, communicating over the network. The trade is simplicity and consistency against independent deploys and scaling.
Is a monolith better than microservices for startups?
For most startups, yes. Their scarce resource is learning speed, and a monolith lets you change the data model and flows in one deploy. Martin Fowler's Monolith First argues the same: do not start with microservices before you know your domain boundaries.
What is a modular monolith?
A modular monolith is one deployable application organized into modules with explicit public APIs and private internals. Modules do not import each other's internal files or read each other's tables. It gives clear ownership without network calls and makes later extraction easier.
When should you move from a monolith to microservices?
Move when a measured pain appears that boundaries and workers cannot fix: teams blocked by a shared release, a module with a very different scaling or runtime need, or a separate compliance boundary. Get a clean module boundary first, then extract with the strangler pattern, one service at a time.
How many developers do you need for microservices?
There is no fixed number. As a rule of thumb, you need enough engineers for each service to have an owning team that can run it on call, which usually means several teams rather than one squad. Under about a dozen engineers, the overhead usually outweighs the gain.
Can Next.js be used as a monolith?
Yes. Next.js is a full-stack framework, and its route handlers, server actions and server components can carry the interface, the API and the business logic in one deployable app. Keep logic in modules with public APIs so the app stays a modular monolith.