Microservices vs Monolith: When Should You Split Apps?
Understand the microservices vs monolith trade-off and learn the concrete signals that justify splitting your app into services rather than staying modular.

The default recommendation in most architecture discussions has quietly shifted. A few years ago, teams were urged to "go microservices from day one." Today, after watching dozens of premature splits create operational nightmares, the modular monolith has earned a serious second look. The microservices vs monolith debate isn't really about which pattern is better in the abstract. It's about matching architecture to the actual signals your product is sending you.
The problem we keep seeing: teams split their applications into services before they've found stable service boundaries. The result is a distributed monolith with all the complexity of microservices and none of the independence benefits. Deployments become a coordination ballet, local development requires running eight containers, and a single customer request touches six services across three failure domains.
We've helped teams on both ends of this. Some need to consolidate a fragmented system back into a well-structured monolith. Others have a single-process application that's genuinely being held back by it. The decision turns on a handful of concrete signals, not on what architecture is trending on social media.
What you'll learn
- Why the modular monolith is the right default
- What a modular monolith actually looks like
- The real costs of microservices
- Monolith vs microservices comparison
- Concrete signals that justify a split
- How to extract a service without breakage
- Frequently Asked Questions
Why the Modular Monolith Is the Right Default
A modular monolith is a single deployable unit whose internal structure is divided into well-defined modules with explicit boundaries and no cross-cutting dependencies. It deploys as one process but evolves like a set of loosely coupled components.
This matters because most architectural mistakes that push teams toward microservices are actually problems of poor internal structure, not deployment topology. An unstructured monolith (where any file can import any other file, where domain logic leaks across layers, where the database schema is a shared global) is genuinely painful. But that pain doesn't go away when you distribute it. It gets worse.
A well-structured monolith, by contrast, gives you:
- Cheap refactoring. Moving a function between modules is a local operation. Moving a function between services requires versioned APIs, migration planning, and coordination across team boundaries.
- Simple local development. One process, one database, one command to run the whole system.
- Reliable transactions. ACID semantics are free inside a single database. Distributed transactions are a design problem you pay for every day.
- Straightforward debugging. A stack trace spans the full call chain. In a distributed system, you need distributed tracing just to follow a single request.
The honest argument for starting with a monolith isn't that microservices are bad. It's that you probably don't know your service boundaries yet, and discovering them through code is safer than embedding bad guesses into network topology.
What a Modular Monolith Actually Looks Like
Structure is the thing that separates a maintainable monolith from the spaghetti everyone complains about. A module in this context is a directory (or package) that owns its own domain logic, data access, and public API surface, with linting rules or architecture tests that prevent other modules from importing its internals.
Here's a concrete directory structure for a Node.js application:
src/
modules/
billing/
api.ts # public interface for other modules
service.ts # domain logic
repository.ts # data access
index.ts # re-exports only the public API
auth/
api.ts
service.ts
repository.ts
index.ts
notifications/
api.ts
service.ts
repository.ts
index.ts
shared/
database/
types/
utils/
The rule: billing/service.ts can import from auth/api.ts but never from auth/service.ts or auth/repository.ts. You enforce this with a tool like eslint-plugin-import or nx boundary rules. When you violate the rule, the linter fails the commit.
This structure also makes future extraction cheaper. If billing later needs to become its own service, its public interface is already defined. You replace the direct function call with an HTTP or message-queue call, and the rest of the system doesn't need to change.
The Real Costs of Microservices
Teams often enumerate microservices benefits (independent deployment, technology diversity, team autonomy) without accounting for what those benefits actually cost in operational overhead.
Network boundary tax. Every in-process function call that becomes an HTTP request adds latency, introduces a failure mode, and requires retry logic, timeout handling, and circuit breakers. A call that took under 1ms now takes 5-50ms and can fail.
Distributed data problems. Keeping related data consistent across service databases requires either eventual consistency (which your UI and business logic must explicitly handle) or distributed sagas (which are complex to implement and even harder to debug).
Observability cost. You need distributed tracing, centralized log aggregation, and service mesh monitoring just to answer "why did this request fail?" In a monolith, a single APM trace tells the whole story.
Developer experience degradation. Running the full system locally requires Docker Compose or a service catalog. Onboarding a new engineer means getting every service running, not just one repo. This is a real productivity tax paid every day.
Deployment coordination. Services that share a data contract still need to be deployed together, or you need to version APIs carefully. Either way, "independent deployment" is only independent if you've actually achieved zero shared state.
None of this means microservices are wrong. It means they're a trade you should make deliberately, not by default.
Monolith vs Microservices Comparison
| Dimension | Modular Monolith | Microservices |
|---|---|---|
| Initial development speed | Fast — single codebase, shared tooling | Slower — service scaffolding, API contracts upfront |
| Local dev complexity | Low — one process | High — multiple services, Docker Compose |
| Deployment | Single artifact | Per-service pipelines, orchestration layer |
| Inter-module calls | In-process, sub-millisecond | Network, 5-50ms + retry/timeout overhead |
| Data consistency | ACID transactions | Eventual consistency or distributed sagas |
| Scaling granularity | Whole-app horizontal scaling | Per-service scaling |
| Team boundaries | Module ownership conventions | Hard service boundaries enforce team separation |
| Debugging | Single stack trace | Distributed tracing required |
| Right team size | 1-30 engineers | 30+ engineers, multiple autonomous teams |
The scaling granularity column deserves a note. For most applications, scaling the whole app horizontally is cheaper and simpler than per-service scaling. You only need fine-grained scaling when different parts of your system have dramatically different load profiles. A video transcoding pipeline that needs 50x more capacity than the user authentication service is the textbook case.
Concrete Signals That Justify a Split
This is the section most posts skip. Here are the actual signals that tell you a component is ready to become a service:
Different scaling requirements. If one part of your system needs 20 instances during peak load and another needs 2, you're wasting resources scaling them together. Extract the high-load component.
Different release cadences owned by different teams. When team A can't ship because team B's unrelated feature is being reviewed in the same monorepo PR, you have a coupling problem that service boundaries solve. This only applies when teams are large enough to have this friction.
Genuinely different reliability requirements. If your core checkout flow needs five-nines availability and your recommendation engine can tolerate degraded responses, isolating them means a recommendation failure doesn't take down checkout.
Regulatory or compliance isolation. Payment card data, health records, and similar regulated data often need to be in explicitly isolated environments. A service boundary with its own database and network perimeter makes compliance audits tractable.
Technology mismatch. If a compute-heavy ML inference pipeline would be better served by Python/CUDA and the rest of the app is Node.js, extracting it as a service lets you use the right tool without contaminating the main codebase.
Team autonomy at scale. Conway's Law is real: systems tend to mirror the communication structure of the teams that build them. When you have 50+ engineers across 6+ autonomous product teams, service boundaries enforce the organizational structure you need to avoid cross-team bottlenecks.
What's conspicuously absent from this list: "we expect to scale someday," "microservices are best practice," or "our CTO worked at Netflix." These are not signals. They're premature optimization dressed up as architecture.
How to Extract a Service Without Breakage
If you've identified a genuine signal and decided to extract a module into a service, the process matters as much as the destination.
The strangler fig pattern is the most reliable approach. Don't rewrite. Wrap:
// Step 1: Define the interface before you move anything
interface BillingService {
createInvoice(customerId: string, lineItems: LineItem[]): Promise<Invoice>;
getInvoice(invoiceId: string): Promise<Invoice>;
}
// Step 2: Implement it with the existing in-process code
class InProcessBillingService implements BillingService {
async createInvoice(customerId: string, lineItems: LineItem[]) {
return billingModule.createInvoice(customerId, lineItems);
}
}
// Step 3: Implement it with the new HTTP client — same interface
class HttpBillingService implements BillingService {
async createInvoice(customerId: string, lineItems: LineItem[]) {
const response = await this.client.post('/invoices', { customerId, lineItems });
return response.data;
}
}
// Step 4: Feature-flag the swap
const billingService: BillingService = config.useRemoteBilling
? new HttpBillingService(config.billingServiceUrl)
: new InProcessBillingService();
This pattern lets you test the new service in production with a subset of traffic before committing to the full cut-over. It also gives you an instant rollback path: flip the feature flag, and you're back to the monolith.
The database is the hard part. Don't share databases between services, but don't split them on day one either. Run the new service against a schema that it owns exclusively, and use an event or migration to populate it from the monolith's data. Accept that you'll have a transition period of dual-writes. Plan for it rather than treating it as a temporary hack.
Our team at Laxaar typically runs a strangler fig extraction over 2-4 weeks, with the new service in shadow mode (receiving real requests but discarding responses) for at least a week before it handles live traffic. It's slower than a big-bang cut-over, but the failure rate is much lower.
When you're planning this kind of custom software development work, the extraction sequence matters as much as the target architecture. A service that's extracted cleanly becomes an asset. One that's extracted hastily becomes a new liability: a distributed monolith with all the costs and none of the benefits.
If your team needs help designing the right architecture for your product or untangling an existing system, the Laxaar web development team has done this across a range of product types and scales. We're also happy to scope what a product engineering engagement looks like if you're facing a significant refactor.
Frequently Asked Questions
Is a monolith always the right starting point?
Almost always, yes. The main exception is when you have genuinely independent products being built by separate teams from day one. In that case, separate repositories and deployments make sense structurally. But if you have a single product and a single team, a modular monolith lets you move fast while keeping the option to extract services later when you have real evidence of where the boundaries should be.
How big does a team need to be before microservices make sense?
There's no hard number, but the friction threshold is usually around 30-50 engineers working on the same codebase. Below that, the coordination overhead of maintaining multiple services typically costs more than it saves. Above that, team autonomy and release independence start to genuinely justify the operational overhead.
What's a distributed monolith and why is it bad?
A distributed monolith is a system that has been split into multiple services but hasn't achieved service independence. The services share a database, make synchronous calls to each other in tight chains, or must be deployed together to avoid breaking changes. It combines the operational complexity of microservices with the coupling of a monolith. It's worse than either option done well, and it's the most common outcome of premature splitting.
Can you use both patterns in the same system?
Yes, and this is common in practice. You might run a modular monolith for your core product with two or three extracted services for genuinely different workloads: a background job processor, a document conversion pipeline, or a third-party webhook handler. The key is that each extracted service meets one of the real signals for splitting rather than being split for architectural aesthetics.
How do service boundaries relate to domain-driven design?
Bounded contexts from domain-driven design (DDD) are the cleanest guide to service boundaries. A bounded context is a part of your domain where a specific model applies consistently, where the word "customer" means the same thing everywhere inside it. These contexts make good service candidates because they have natural data ownership and limited external dependencies. But you can model bounded contexts inside a monolith first (as modules) and extract them later if the signals appear. DDD doesn't require distributed systems.
Getting the architecture right at the beginning saves months of refactoring later. Whether you're starting fresh or trying to decide if it's time to split a module out, the Laxaar team can help you assess the trade-offs and design a path that matches your actual team size and product stage. Talk to us about your project. We're direct about what we'd recommend and why.
Working on something like this?
Get a fixed scope, timeline, and price within one business day — no obligation.


