The Architect’s Case for the Monolith
A complete, beginner‑friendly tutorial on when to stay with a monolith instead of splitting into microservices — the reasoning, the trade‑offs, the numbers, and the real production stories behind the decision.
Introduction & History
Almost every engineering team eventually asks the same question: should we break our one big application into a “small city” of services, or is it wiser to keep living in the house we already have?
Imagine you are building a house. In the beginning, you build one building: a kitchen, a bedroom, a living room, all under one roof, sharing one front door, one electricity meter, and one water pipe. This is simple. You know exactly where everything is, and if something breaks, you go to that one house and fix it.
Now imagine a small city. Instead of one house, there are separate buildings: a bakery, a school, a hospital, a post office. Each building has its own staff, its own door, its own rules. If the bakery catches fire, the hospital keeps running. But now you need roads to connect the buildings, traffic rules, postal services between them, and a lot more coordination.
This is, at its heart, the difference between a monolith (the one big house) and microservices (the small city of separate buildings). This tutorial is about the very practical, very common real‑world question that every engineering team eventually asks: “Should we break our one big house into a small city, or should we keep living in the house we already have?”
A short history of how we got here
In the 1990s and early 2000s, almost every application was built as a single, large program. This was not a “choice” in the modern sense — it was simply how software was built. You wrote your code, packaged it into one deployable unit (a WAR file, an EXE, a single running process), and deployed it on one or a handful of servers. This approach later got the name monolith (from Greek: “mono” = one, “lithos” = stone — one single block of stone).
As companies like Amazon, Netflix, and eBay grew from thousands to hundreds of millions of users, their single big applications started to strain. Small teams had to coordinate deployments with hundreds of other engineers. A tiny bug in the “recommendation” part of the code could crash the entire “checkout” part, because everything lived in the same process. Around 2011–2014, the term microservices became popular to describe an alternative style: many small, independently deployable services, each owning a narrow piece of business functionality, communicating over the network.
For most of the last decade, “microservices” was treated in the industry almost like a fashion trend — the assumption was “bigger companies use microservices, so we should too.” But since around 2018–2020, and especially by the mid‑2020s, there has been a strong, well‑documented, industry‑wide correction. Many companies — including some very famous ones — have publicly gone back from microservices to monoliths, or adopted a middle‑ground design. This tutorial explains exactly why, and gives you a clear, defensible framework for making this decision yourself.
A monolith is like one family living in one house and sharing one kitchen. Microservices are like the same family living in ten different apartments across the city, each with its own kitchen, so that if one apartment has a plumbing problem, the rest are unaffected — but now everyone has to travel to have dinner together, and grocery shopping is ten times more complicated.
This tutorial will not tell you that monoliths are always good or that microservices are always bad. Instead, it will teach you how to think like a senior architect: how to read the signals in your own team, product, and traffic, and make the right call — including the increasingly common and often correct call to stay with a monolith.
The Problem & Motivation
Architecture decisions are some of the most expensive decisions a team can make. Choosing the wrong one does not fail loudly on day one — it fails slowly, over months and years, through frustrated engineers, ballooning cloud bills, and slower feature delivery.
The core motivation for microservices
Microservices were invented to solve real, painful problems that show up at a certain scale:
- Team scaling: when 200 engineers work on one codebase, they constantly step on each other’s changes.
- Independent deployment: a team wants to release its own feature without waiting for 50 other teams to finish testing theirs.
- Independent scaling: one part of the system (say, video encoding) needs 1,000 servers, while another part (say, user settings) needs only 2. In a monolith, you must scale the whole thing together.
- Technology diversity: one team wants to use Python for machine learning; another wants Java for transaction processing.
- Fault isolation: if one small service crashes, the rest of the system should keep working.
The problem this tutorial addresses
The real‑world problem is this: teams often adopt microservices before they actually have these problems. A five‑person startup with 200 users does not have a “200 engineers stepping on each other” problem. A product with predictable, low traffic does not have an “independent scaling” problem. Yet these teams still split into 30 microservices — because it is trendy, because a conference talk made it sound exciting, or because an engineer wants it on their résumé.
The result is what senior engineers now half‑jokingly call a “distributed monolith” — all the operational pain of microservices (network calls, deployment pipelines, service discovery, distributed debugging) with none of the benefits (because the services are still so tightly coupled that you cannot actually deploy or scale them independently).
Splitting your application into microservices because “that is what Netflix does” is like a small neighbourhood bakery buying an industrial flour mill because a giant national bread company owns one. The bakery does not have the volume to justify the machine, and now it has to hire someone just to maintain the mill.
Why “staying with a monolith” is a legitimate engineering decision
This tutorial’s central motivation is to correct an industry bias. For years, choosing a monolith was seen as “not scaling,” “old‑fashioned,” or “not serious engineering.” In reality, staying with a monolith is very often the more sophisticated, more disciplined decision, because it requires you to resist hype and instead measure your actual problems.
By the end of this tutorial, you will be able to look at a real team, a real product, and real traffic numbers, and confidently say: “Based on these specific signals, we should stay with our monolith” — or “based on these other specific signals, we are ready to split out services” — and explain exactly why, in a way that would satisfy a room full of senior engineers or a technical interviewer.
Core Concepts
Before comparing architectures, it helps to lock down a small, precise vocabulary you will meet again in interviews, code reviews, and production war stories.
3.1 What is a Monolith?
What it is: a monolithic architecture is an application built and deployed as a single, unified unit. All the code — user interface, business logic, and data access — lives in one codebase and runs as one process (or a small number of identical copies of that same process).
Why it exists: it is the natural, simplest way to build software. You do not need to invent network boundaries between parts of your own program that do not need them.
Where it is used: most startups, most internal business tools, most small‑to‑medium products, and — importantly — many very large, very successful products (Shopify, Stack Overflow, and Basecamp are famous examples) run as monoliths, sometimes handling billions of requests.
Think of a simple online bookstore app. One codebase contains: the code that shows the book catalog, the code that handles the shopping cart, the code that processes payment, and the code that sends confirmation emails. All of this is compiled and deployed together as one running application — for example, one Java .jar file running on a server.
3.2 What are Microservices?
What it is: microservices architecture splits an application into many small, independent services. Each service owns one specific business capability (for example, “Payments,” “Inventory,” “Shipping”), has its own codebase, its own database (usually), and communicates with other services over the network — typically through HTTP/REST APIs or asynchronous messaging.
Why it exists: to let large organisations split work across many independent teams, deploy pieces of the system separately, and scale only the parts that need it.
Where it is used: large‑scale platforms with many independent teams and highly variable load across different parts of the system — Netflix, Amazon, Uber, and other Uber‑scale ride‑sharing and streaming platforms are classic examples.
The same bookstore, rebuilt as microservices, would have a separate “Catalog Service,” a separate “Cart Service,” a separate “Payment Service,” and a separate “Email Service” — each is its own small program, deployed separately, each with (often) its own database, talking to each other over the network.
3.3 The Modular Monolith (the important middle ground)
What it is: a modular monolith is a single deployed application (like a monolith), but internally organised into clean, well‑separated modules with clear boundaries and defined interfaces between them — as if each module were “pre‑packaged” to become its own microservice one day, without actually paying the network cost today.
Why it exists: to get the simplicity of one deployment while still avoiding the “big ball of mud” problem where every part of the code touches every other part with no boundaries.
Where it is used: this is the design most senior architects recommend today as the default starting point, and increasingly, as a permanent architecture, for the majority of products.
A modular monolith is like one big house that has clearly separate rooms with proper doors — a kitchen, a bedroom, an office — rather than one giant open room where the sofa is on top of the stove. You still share one front door and one electricity bill (one deployment), but the rooms do not spill into each other (clean internal boundaries).
3.4 Key Vocabulary You Need
| Term | Simple definition |
|---|---|
| Coupling | How much one part of the code depends on and knows about another part. High coupling = hard to change one part without breaking another. |
| Cohesion | How well the responsibilities inside one module belong together. High cohesion = a module does one clear job well. |
| Bounded Context | A clear boundary around a business area (e.g., “Billing”) where specific terms and rules apply consistently, without leaking into other areas. |
| Latency | The delay between asking for something and getting a response. Network calls between services add latency that in‑process function calls do not have. |
| Distributed system | A system made of multiple independent computers (or processes) that must communicate over a network to do a single job together. |
| Deployment unit | The single package of code that gets deployed and run together, as one thing. |
| CAP Theorem | A rule stating that in a distributed system, when a network problem (Partition) happens, you must choose between staying fully Consistent or staying Available — you cannot have both perfectly. |
3.5 The One‑Sentence Answer to the Tutorial’s Question
The rest of this tutorial teaches you how to recognise each of those signals concretely.
Architecture & Components
A well‑built monolith is not “one file with 50,000 lines of spaghetti code.” It is a disciplined, layered structure — and it looks very different from a healthy microservices system on the moving‑parts level.
4.1 The anatomy of a monolith
A good monolith has clean internal layers, usually organised like this:
4.2 The anatomy of microservices
4.3 Comparing the moving parts
| Component | Monolith | Microservices |
|---|---|---|
| Codebase | One repository (usually) | Many repositories (usually) |
| Deployment | One artifact, one pipeline | Many artifacts, many pipelines |
| Database | Typically one shared database | Typically one database per service |
| Communication | In‑process function calls | Network calls (HTTP, gRPC, messaging) |
| Scaling | Scale the whole app | Scale each service independently |
| Failure boundary | A crash can affect the whole app | A crash is (ideally) contained to one service |
| Required infrastructure | Minimal — a server + a database | Service discovery, API gateway, message broker, distributed tracing, container orchestration |
Shopify famously runs a large Ruby on Rails monolith at massive scale, using internal modularisation (“components”) rather than splitting into hundreds of services, precisely because the operational simplicity of one deployable unit outweighed the benefits of full service separation for their situation.
Internal Working
Trace a single request through both worlds. The same “Buy Now” button turns into a very different journey depending on whether you are inside a monolith or a network of services.
5.1 How a request flows through a monolith
Let’s trace a real example: a user clicks “Buy Now” on a bookstore website built as a monolith.
1. Browser sends an HTTP request to the server.
Standard start of any web transaction.
2. A single running process receives the request at a controller (e.g.,
OrderController).Everything else will happen inside this same process.
3. The controller calls the
OrderService.This is a plain in‑memory function call, taking microseconds, not a network call.
4.
OrderServicecallsInventoryServiceandPaymentService.Again, plain function calls within the same process.
5. All three services use the same shared database connection pool.
Reads and writes happen inside a single database transaction, so either everything succeeds together or everything rolls back together.
6. The controller returns an HTTP response.
One thread, one continuous stack, one clear story.
Notice: steps 3, 4, and 5 never leave the process. There is no network involved. This is why monoliths are inherently simpler to reason about — a bug or slowdown in one part is visible in the same stack trace, the same logs, the same debugger session.
5.2 A minimal Java monolith example
Below is a simplified but realistic slice of a Java monolith. Notice that OrderService simply calls other services directly as Java objects — no network layer involved.
// A single Spring Boot application - ALL of this runs in one process
@Service
public class OrderService {
private final InventoryService inventoryService;
private final PaymentService paymentService;
private final NotificationService notificationService;
public OrderService(InventoryService inventoryService,
PaymentService paymentService,
NotificationService notificationService) {
this.inventoryService = inventoryService;
this.paymentService = paymentService;
this.notificationService = notificationService;
}
@Transactional // one database transaction covers the whole operation
public OrderResult placeOrder(OrderRequest request) {
// Direct, in-memory method calls - no network, no timeouts, no retries
boolean reserved = inventoryService.reserveStock(request.getItemId(), request.getQty());
if (!reserved) {
return OrderResult.failed("Out of stock");
}
boolean charged = paymentService.charge(request.getCustomerId(), request.getAmount());
if (!charged) {
inventoryService.releaseStock(request.getItemId(), request.getQty());
return OrderResult.failed("Payment declined");
}
notificationService.sendOrderConfirmation(request.getCustomerId());
return OrderResult.success();
}
}
Because InventoryService, PaymentService, and NotificationService are just Java classes in the same process, the @Transactional annotation can wrap all their database work into one atomic transaction. If anything fails, the database automatically rolls everything back. This kind of guarantee is easy in a monolith and genuinely hard in microservices.
5.3 The same flow in microservices (for comparison)
// Order Service - now a SEPARATE deployed application
@Service
public class OrderService {
private final RestTemplate restTemplate; // network client
public OrderResult placeOrder(OrderRequest request) {
// This is now a real HTTP call over the network - can time out, fail, retry
ResponseEntity<Boolean> reserved = restTemplate.postForEntity(
"http://inventory-service/api/reserve", request, Boolean.class);
if (!Boolean.TRUE.equals(reserved.getBody())) {
return OrderResult.failed("Out of stock");
}
ResponseEntity<Boolean> charged = restTemplate.postForEntity(
"http://payment-service/api/charge", request, Boolean.class);
if (!Boolean.TRUE.equals(charged.getBody())) {
// Must manually call another network endpoint to undo the reservation
restTemplate.postForEntity("http://inventory-service/api/release", request, Void.class);
return OrderResult.failed("Payment declined");
}
// Fire-and-forget event, usually via a message broker like Kafka
// eventPublisher.publish(new OrderPlacedEvent(request));
return OrderResult.success();
}
}
Every arrow is now a real network call. If payment-service is slow or down, order-service must handle timeouts, retries, and partial failures. There is no automatic database transaction spanning both services — this is why microservices architectures need patterns like the Saga pattern (explained in Chapter 15) to keep data consistent, which is significantly more complex than a single @Transactional annotation.
This single code comparison is often the clearest, most convincing argument in favour of staying with a monolith for small‑to‑medium systems: you trade one annotation and a guaranteed rollback for a small library of retry logic, compensating transactions, and failure‑handling code — and someone has to write, test, and maintain all of that failure‑handling code forever.
Data Flow & Lifecycle
One process, one transaction — or five actors and three network hops. The lifecycle of a request looks very different depending on which world you are in.
6.1 Request lifecycle in a monolith
6.2 Request lifecycle in microservices
6.3 The application lifecycle (deployment over time)
In a monolith, the lifecycle of a change is: write code → run tests → build one artifact → deploy one artifact → done. In microservices, the lifecycle multiplies: each of the (say) 15 services has its own version, its own pipeline, its own rollout schedule, and teams must track “which version of Service A is compatible with which version of Service B” — a real and often underestimated coordination cost.
A five‑person startup team pushes 10 small changes a day. In a monolith, that is 10 quick deployments of one artifact. In a 12‑service microservices setup, the team must maintain 12 separate CI/CD pipelines, 12 sets of health checks, and 12 versioning schemes — for the same 10 changes a day. This is a very common early symptom that a team has “over‑split” too early.
Advantages, Disadvantages & Trade‑offs
Neither architecture is universally “better.” Each earns its keep in specific situations, and each has genuine, well‑known costs. Here they are, plainly.
7.1 Advantages of staying with a monolith
Pros
- Simplicity: one codebase, one deployment, one place to look for bugs.
- Faster development early on: no need to design network contracts between every module.
- Easier debugging: a single stack trace shows the full path of a request, instead of stitching together logs from five services.
- Strong data consistency: one shared database with real ACID transactions, no need for complex distributed‑consistency patterns.
- Lower operational cost: no service mesh, no API gateway, no message‑broker cluster, no per‑service on‑call rotation.
- Lower latency: in‑process calls are measured in nanoseconds; network calls are measured in milliseconds.
- Easier onboarding: a new engineer can run the whole application on their laptop in minutes.
- Refactoring is cheap: renaming a class or moving code between modules is a simple, safe, compiler‑checked operation — moving functionality between microservices means renegotiating a network API.
Cons
- Scaling is all‑or‑nothing: you cannot scale just the “image processing” part without scaling everything.
- Blast‑radius risk: a memory leak or crash in one module can bring down the entire application.
- Deployment coupling: every team must coordinate around the same release train.
- Technology lock‑in: the whole application is generally built in one language/framework.
- Codebase growth pains: without discipline, a monolith can decay into a “big ball of mud” that is hard to navigate.
- Long build/test times at large scale: a huge codebase can mean a slow CI pipeline if not carefully managed.
7.2 Advantages and disadvantages of microservices (for context)
Pros
- Independent scaling of each part of the system.
- Independent deployment — one team’s release does not block another’s.
- Fault isolation between services.
- Freedom to choose different technologies per service.
- Clear ownership boundaries for large organisations.
Cons
- Massive operational complexity: service discovery, distributed tracing, network retries, circuit breakers.
- Data consistency becomes hard — no more simple database transactions across services.
- Higher latency due to network hops.
- Debugging a single user request may require reading logs from ten different services.
- Significant infrastructure and cloud cost (each service needs its own resources, monitoring, and often its own database).
- Requires mature DevOps practices and tooling that small teams often do not have yet.
7.3 The trade‑off summary table
| Dimension | Monolith wins when… | Microservices win when… |
|---|---|---|
| Team size | Small team (1–2 “two‑pizza” teams) | Many independent teams (10+) |
| Traffic pattern | Fairly uniform load across features | Wildly different load per feature |
| Domain clarity | Business boundaries still evolving | Domain boundaries well understood and stable |
| Consistency needs | Strong transactional consistency required | Eventual consistency is acceptable |
| Operational maturity | Little/no dedicated DevOps/SRE team | Mature CI/CD, observability, on‑call culture |
| Time to market | Need to move fast, validate product‑market fit | Product is proven, now optimising for scale |
Performance & Scalability
A monolith is not slow just because it is a monolith. Almost every performance ceiling that people blame on “the monolith” turns out to be a database, caching, or algorithmic issue that would exist in microservices too.
8.1 Vertical vs. horizontal scaling
Vertical scaling means giving your one server more CPU and RAM (a bigger machine). Horizontal scaling means running more copies of your application across more machines. A key myth to bust: monoliths can absolutely scale horizontally. You simply run 10 identical copies of the same monolith behind a load balancer.
What a monolith cannot do is scale each internal module independently. If “search” needs 50 instances but “user profile” needs 2, a monolith forces you to run 50 full copies of everything, wasting resources on the parts that did not need scaling. This is the single strongest, most legitimate technical argument for microservices — but it only matters once your traffic is large enough and lopsided enough for the wasted resources to actually cost more than the operational overhead of splitting.
8.2 When uneven load does not justify splitting
A company’s monolith uses 4 GB of RAM and 2 CPU cores per instance, and they run 6 instances for redundancy and load. Even if “search” is 80 % of the CPU usage, running 6 full copies is cheap — a few hundred dollars a month on modern cloud pricing. Splitting “search” into its own service to save that cost would require weeks of engineering time, new infrastructure, and ongoing maintenance — a bad trade at this scale.
8.3 Caching in a monolith
A monolith can still get excellent performance through caching layers — for example, an in‑memory cache (like Caffeine in Java) for frequently‑read, rarely‑changed data, and a shared cache (like Redis) for data that must be consistent across multiple instances.
@Service
public class ProductCatalogService {
private final Cache<String, Product> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build();
public Product getProduct(String productId) {
return localCache.get(productId, id -> loadProductFromDatabase(id));
}
}
This is a simple in‑process cache. It requires zero extra infrastructure — no separate cache cluster to run and monitor — because everything lives inside the same monolith process. This is a good example of a “microservices‑style” performance technique that a monolith can adopt for free.
8.4 Algorithmic and concurrency considerations
Performance inside a monolith often comes down to classic computer‑science fundamentals rather than architecture style:
- Concurrency: Java’s thread pools (
ExecutorService), or newer lightweight virtual threads (Project Loom, standard since Java 21), let a single monolith process handle tens of thousands of concurrent requests efficiently. - Connection pooling: a well‑tuned database connection pool (e.g., HikariCP) lets one monolith serve very high query volumes.
- Indexing and query optimisation: a single well‑indexed database, with good understanding of Big‑O complexity for queries, often outperforms a distributed system where the same query now requires joining data across network calls (a “distributed join”).
Stack Overflow, for many years, served enormous global traffic (billions of page views per month) from a small number of physical servers running a .NET monolith, heavily optimised with caching and efficient SQL Server queries — proof that a well‑tuned monolith can achieve serious scale without becoming microservices.
High Availability & Reliability
High availability is not a microservices feature. It is a property you earn by running multiple copies across failure domains — and a monolith can do that just as well as a service‑based system.
9.1 Redundancy without microservices
High availability means the system keeps working even when parts of it fail. A monolith achieves this the same way most systems do: by running multiple identical copies across different servers, data centres, or cloud “availability zones,” so if one fails, others take over.
9.2 The CAP theorem and why it matters less here
The CAP theorem states that during a network partition (a communication failure between nodes), a distributed system must choose between Consistency (everyone sees the same data) and Availability (the system keeps responding). A monolith with one database mostly sidesteps this dilemma for its own internal logic, because there is no network partition possible between “modules” that live in the same process — the only place CAP applies is between the monolith and its database replicas, which is a much smaller, well‑understood problem that database vendors have already solved with mature replication technology.
In a monolith, all the “rooms” (modules) are in the same building, so they never lose contact with each other — you can always walk from the kitchen to the bedroom. In microservices, the rooms are different buildings connected by roads (the network), and the CAP theorem is really just asking: “What do you do when a road is blocked?”
9.3 Failure recovery patterns in a monolith
- Health checks + auto‑restart: if an instance crashes, the orchestrator (e.g., Kubernetes) restarts it automatically.
- Database failover: if the primary database fails, a replica is promoted automatically.
- Graceful degradation: a monolith can catch exceptions from a non‑critical module (e.g., “recommendations”) so that a failure there does not take down “checkout,” even within the same process — using techniques like bulkheading and try/catch isolation, similar in spirit to microservice fault isolation but without the network.
public OrderResult placeOrder(OrderRequest request) {
OrderResult result = coreOrderService.placeOrder(request);
// Non-critical module: isolate its failure so it never breaks the order flow
try {
recommendationService.recordPurchaseForRecommendations(request);
} catch (Exception e) {
log.warn("Recommendation tracking failed, continuing anyway", e);
}
return result;
}
Even inside one process, you can design “soft” boundaries so a non‑critical feature’s failure does not cascade. This gives you a meaningful slice of microservices’ fault‑isolation benefit, without any network calls.
Security
Every network endpoint is a potential entry point for an attacker. A monolith typically exposes far fewer network endpoints than a microservices system, which materially shrinks the attack surface.
10.1 A smaller attack surface
In a monolith, internal module‑to‑module communication never leaves the process and therefore is never exposed to the network at all.
| Security concern | Monolith | Microservices |
|---|---|---|
| Internal communication exposure | None — in‑process calls only | Every service‑to‑service call is a network request that must be secured (mTLS, tokens) |
| Authentication complexity | One login/session system | Must propagate identity (e.g., JWT) across every service |
| Number of deployable attack surfaces | One | One per service (10, 20, 100+) |
| Secrets management | One set of credentials to rotate/secure | Credentials per service, per database |
| Patch management | Patch once, redeploy once | Patch and redeploy potentially dozens of services |
10.2 Practical security practices for a monolith
- Input validation at the controller layer to block injection attacks (SQL injection, XSS).
- Parameterised queries / ORM usage (e.g., JPA/Hibernate) instead of building raw SQL strings.
- Role‑based access control (RBAC) enforced centrally, since there is only one place to enforce it.
- Encryption in transit (TLS) between the client and the monolith, and encryption at rest for the database.
- Centralised audit logging — since everything runs in one process, you get one unified audit trail “for free,” instead of stitching audit logs from many services.
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@PreAuthorize("hasRole('CUSTOMER')") // enforced in one central place
@PostMapping
public ResponseEntity<OrderResult> placeOrder(@Valid @RequestBody OrderRequest request) {
// @Valid triggers input validation before any business logic runs
return ResponseEntity.ok(orderService.placeOrder(request));
}
}
@Valid and @PreAuthorize are Spring Security/Validation annotations. Because the whole request pipeline lives in one process, security rules are easy to apply consistently everywhere, with far less risk of one forgotten, unsecured internal endpoint (a very common real‑world microservices vulnerability).
Monitoring, Logging & Metrics
Observability is the ability to understand what is happening inside your system from the outside — through logs, metrics, and traces. It is dramatically simpler in a monolith.
11.1 Why observability is simpler in a monolith
In a monolith, one request generates one continuous stack trace and one continuous log stream. In microservices, the same request is scattered across multiple services’ logs, requiring a distributed tracing system (like Jaeger or Zipkin) just to reconstruct what happened.
11.2 Practical monitoring setup for a monolith
- Application metrics: response times, error rates, request counts — easily exposed via a library like Micrometer and visualised in Grafana.
- Health endpoints: a single
/actuator/healthendpoint (Spring Boot) tells you the state of the entire application. - Structured logging: logging in JSON format so logs can be indexed and searched (e.g., with the ELK stack: Elasticsearch, Logstash, Kibana).
- Alerting: a small number of alerts (CPU, memory, error rate, response time) cover the whole system, instead of needing separate alert rules per microservice.
// Exposing a simple custom metric with Micrometer in a Spring Boot monolith
@Service
public class OrderService {
private final Counter ordersPlacedCounter;
public OrderService(MeterRegistry registry) {
this.ordersPlacedCounter = Counter.builder("orders.placed.total")
.description("Total number of orders placed")
.register(registry);
}
public OrderResult placeOrder(OrderRequest request) {
OrderResult result = /* ... business logic ... */ null;
ordersPlacedCounter.increment();
return result;
}
}
This one counter, exposed automatically to a monitoring system like Prometheus, gives visibility into the whole “orders placed” business metric — no need to aggregate this number across multiple separately‑deployed services.
Deployment & Cloud
A monolith on modern cloud infrastructure gets almost all the deployment sophistication people associate with microservices — rolling updates, health checks, container orchestration — without needing to run dozens of separate service pipelines.
12.1 The deployment pipeline of a monolith
12.2 Cloud deployment options for monoliths
- Platform‑as‑a‑Service (PaaS) like Heroku, Render, or AWS Elastic Beanstalk — you upload your app, and the platform handles servers, load balancing, and scaling.
- Containerised deployment — package the monolith into a single Docker image and run multiple replicas using Kubernetes, ECS, or a simple container orchestrator, getting the modern deployment benefits (rolling updates, auto‑restart) without the complexity of managing dozens of services.
- Serverless‑adjacent hosting — some cloud providers let you run a “monolith” behind an auto‑scaling group where the number of instances grows and shrinks with load automatically.
A simple Dockerfile for a Java monolith:
FROM eclipse-temurin:21-jre COPY target/bookstore-app.jar /app/app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app/app.jar"]
One image, one ENTRYPOINT. Compare this to a microservices system, where you would maintain a separate Dockerfile, a separate image registry entry, and a separate deployment manifest for every one of your services.
12.3 Rolling updates and zero‑downtime deploys
Modern deployment tools (like Kubernetes) support rolling updates: new instances of the monolith are started, health‑checked, and only then are old instances shut down — giving zero‑downtime deployments even for a “big” application, without needing per‑service release trains.
Basecamp (the project management tool) has publicly and repeatedly advocated for staying with a Ruby on Rails monolith deployed as a single unit, arguing that the operational simplicity lets their small team ship confidently and frequently, without a dedicated platform team.
Databases, Caching & Load Balancing
A shared relational database is not a weakness of a monolith. It is one of its most powerful assets — and it is what unlocks ACID transactions, cheap joins, and referential integrity that microservices spend enormous effort trying to reinvent.
13.1 The shared database model
Monoliths typically use one shared relational database (like PostgreSQL or MySQL). This gives you:
- ACID transactions (Atomicity, Consistency, Isolation, Durability) across all your business logic, guaranteeing that related writes either all succeed or all fail together.
- Simple JOIN queries across related data (e.g., joining “orders” with “customers” with “products”) in a single, fast SQL query — instead of making three network calls and joining data manually in application code (a “distributed join,” which is slower and more error‑prone).
- Referential integrity enforced by the database itself (foreign keys), preventing orphaned or inconsistent data.
13.2 Scaling the database without splitting services
- Read replicas: copies of the database that handle read‑heavy traffic, while the primary handles writes.
- Vertical scaling: bigger database instances (more CPU/RAM) as load grows.
- Table partitioning: splitting one huge table (e.g., “orders” with 500 million rows) into smaller partitions by date or region, invisible to the application code.
- Caching layers: Redis or Memcached in front of the database for hot, frequently‑read data.
13.3 Load balancing a monolith
A load balancer is a component that distributes incoming requests across multiple servers, so no single server is overwhelmed. Common algorithms include:
- Round robin: requests are sent to each server in turn.
- Least connections: requests go to whichever server currently has the fewest active connections.
- Consistent hashing: useful when you want the same user routed to the same server repeatedly (session stickiness).
Imagine three cashiers (servers) at a supermarket and one person (the load balancer) directing customers to whichever cashier is free. The customers do not know or care which cashier they go to — they just get served quickly. This is exactly what a load balancer does for a monolith’s multiple identical instances.
APIs & Microservices Compared
A monolith still has APIs — it just does not have the network cost between its own modules. Comparing the two side by side makes the trade very concrete.
14.1 How a monolith exposes APIs
Even a monolith exposes external APIs to mobile apps, web front‑ends, or partner integrations — the difference is that internally, those API handlers call regular functions, not other network services.
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderService orderService; // in-process dependency
@PostMapping
public ResponseEntity<OrderResult> create(@RequestBody OrderRequest request) {
return ResponseEntity.ok(orderService.placeOrder(request));
}
@GetMapping("/{orderId}")
public ResponseEntity<Order> get(@PathVariable String orderId) {
return ResponseEntity.ok(orderService.findById(orderId));
}
}
14.2 API communication styles in microservices
- Synchronous REST/HTTP: Service A calls Service B and waits for a response — simple but creates tight runtime coupling and cascading latency.
- gRPC: a faster, binary, strongly‑typed alternative to REST, popular for internal service‑to‑service calls.
- Asynchronous messaging (Kafka, RabbitMQ): Service A publishes an event; other services react independently, without waiting — more resilient, but harder to reason about and debug.
14.3 The API Gateway pattern
In microservices, an API Gateway is a single entry point that routes external requests to the correct internal service, and often handles authentication, rate limiting, and request logging centrally. Interestingly, a monolith gets a “free” API gateway — the monolith’s own controller layer already does all of this routing internally, with no extra component required.
14.4 Side‑by‑side: adding a new feature
| Step | Monolith | Microservices |
|---|---|---|
| 1 | Add a new method to an existing service class | Decide which service should own the new feature, or create a new service |
| 2 | Call it directly from the controller | Define a new API contract (REST/gRPC schema) between services |
| 3 | Write one test | Write unit tests + contract tests + integration tests across services |
| 4 | Deploy once | Deploy the new/changed service(s), verify compatibility with dependent services |
A very common, well‑regarded answer in system‑design interviews is: “I would start with a well‑structured monolith, and only extract a microservice when a specific module has a clearly independent scaling need, a clearly independent team, or a clearly independent release cadence — not before.” This shows judgement, not just knowledge of buzzwords.
Design Patterns & Anti‑patterns
The interesting patterns are not “monolith vs. microservices.” They are the shapes that make either architecture work — and the anti‑patterns that quietly ruin both.
15.1 Good patterns for a monolith
Modular Monolith / Package‑by‑Feature
Organise code by business capability (e.g., com.bookstore.orders, com.bookstore.inventory, com.bookstore.payments) instead of by technical layer (controllers, services, repositories all mixed together). Each package exposes a small, clear public interface, and internal details stay package‑private.
com.bookstore.orders |-- OrderController.java (public API) |-- OrderService.java (public API) |-- OrderRepository.java (package-private) '-- OrderEntity.java (package-private) com.bookstore.inventory |-- InventoryController.java |-- InventoryService.java (public API used by orders module) |-- InventoryRepository.java (package-private) '-- InventoryEntity.java (package-private)
The orders module can call InventoryService (a public class), but cannot directly touch InventoryRepository or InventoryEntity — this keeps a clean boundary, exactly like a microservice boundary, but enforced by the compiler instead of the network.
Strangler Fig Pattern
Named after a vine that slowly grows around a tree and eventually replaces it, this pattern lets you gradually extract functionality out of a monolith into a new service over time, routing a small slice of traffic to the new service while the monolith still handles the rest — instead of a risky “big bang” rewrite.
Saga Pattern (for when you do split)
Since microservices cannot use one shared database transaction, the Saga pattern coordinates a sequence of local transactions across services, with defined “compensating actions” to undo previous steps if a later step fails.
15.2 Anti‑patterns to avoid
The Distributed Monolith (the most dangerous anti‑pattern)
This happens when a team splits into microservices, but the services are so tightly coupled (shared database, synchronous call chains, shared deployment schedule) that they must all be deployed together anyway — giving you all of microservices’ network overhead and complexity, with none of its independence benefits.
Ten “microservices” that all read and write to the exact same shared database, and must all be redeployed together whenever the database schema changes. This is not really microservices — it is a monolith that happens to be split across ten network calls, with strictly worse performance and reliability than the original monolith.
Premature Decomposition
Splitting services based on guesses about future scaling needs, before the team has any real evidence (traffic data, team friction, deployment conflicts) that those specific boundaries are correct. This almost always leads to services being split along the wrong lines, requiring painful re‑splitting later.
The Big Ball of Mud (the monolith anti‑pattern)
This is the failure mode on the other side: a monolith with no internal structure at all, where every class can call every other class with no boundaries. This is the actual thing people are afraid of when they say “monoliths do not scale” — but the fix for a Big Ball of Mud is a Modular Monolith, not necessarily microservices.
Best Practices & Common Mistakes
Discipline beats architecture style. A modular, well‑monitored, feature‑flagged monolith outperforms a chaotic microservices sprawl almost every time.
16.1 Best practices when staying with a monolith
- Organise code by business feature/module, not by technical layer alone.
- Enforce module boundaries with package‑private visibility, build‑tool module systems (e.g., Java Platform Module System), or architectural tests (e.g., ArchUnit) that fail the build if boundaries are violated.
- Keep the database schema clean, with clear ownership of tables by module, even if they share one physical database.
- Invest early in good logging, metrics, and automated tests — these matter just as much in a monolith as in microservices.
- Use feature flags to enable safe, incremental rollouts without needing separate deployable services.
- Keep the build fast — invest in incremental builds and parallel test execution as the codebase grows.
- Revisit the architecture decision periodically (e.g., yearly) using real data, not vibes.
16.2 Common mistakes teams make
- Choosing microservices for résumé‑building or hype reasons rather than actual team/traffic needs.
- Letting a monolith grow without any internal modularity, then blaming “the monolith” instead of the lack of discipline.
- Splitting into microservices along technical lines (e.g., “the database service,” “the API service”) instead of business‑capability lines (e.g., “orders,” “payments”) — this creates chatty, tightly‑coupled services that are worse than the monolith they replaced.
- Underestimating the ongoing operational cost (on‑call burden, tooling, cloud spend) of running many services.
- Migrating to microservices all at once (“big bang”) instead of incrementally, with no way to safely roll back.
- Not measuring anything before deciding — no traffic data, no team‑friction data, just “everyone else is doing it.”
Segment, a data infrastructure company, publicly wrote about splitting their system into over 100 microservices and later consolidating them back down to a single, well‑structured monolith, citing excessive operational overhead and slowed development speed as the reasons. This is one of the most cited real‑world case studies in this exact debate.
Real‑World & Industry Examples
The pattern across public case studies is consistent: match your architecture to your actual, current, measured needs — not to whichever style is fashionable this year.
| Company | Architecture choice | Why (as publicly discussed) |
|---|---|---|
| Shopify | Modular monolith (Ruby on Rails, “components”) | Massive scale achieved through internal modularisation rather than full service splitting; avoids distributed‑system overhead. |
| Basecamp | Monolith (Ruby on Rails) | Small team values simplicity, fast iteration, and low operational burden over independent service scaling. |
| Stack Overflow | Monolith (.NET) for many years | Extremely high traffic served by a small number of well‑optimised, vertically‑scaled servers. |
| Segment | Migrated to microservices, then consolidated back to a monolith | Reported that operational overhead and cross‑service coordination slowed the team down more than it helped. |
| Netflix | Microservices (hundreds of services) | Enormous scale, hugely uneven load across features (streaming vs. billing vs. recommendations), thousands of engineers across independent teams. |
| Amazon | Microservices (pioneered the “two‑pizza team” model) | Needed independent ownership and deployment for thousands of engineers working on one retail platform. |
| Uber | Started as a monolith, moved to microservices as it scaled to thousands of engineers and cities | Reported later needing to reduce microservice sprawl and consolidate some services, a phenomenon they called “service consolidation.” |
The pattern across these real examples is consistent: companies with huge, diverse engineering organisations and highly uneven traffic patterns (Netflix, Amazon) benefit from microservices. Companies that prioritise a small, fast‑moving team and predictable, well‑understood traffic (Shopify, Basecamp, Stack Overflow) have publicly and successfully stayed with monoliths — and at least one company (Segment) tried microservices and consciously reversed the decision after measuring the real cost.
Notice that even Uber — often cited as a microservices success story — later had to actively manage and reduce the number of services it ran, because uncontrolled service growth became its own operational problem. Scale alone does not automatically mean “more services are better” — it means “match your architecture to your actual, current, measured needs.”
The Decision Framework: When to Stay With a Monolith
This chapter is the practical heart of the tutorial: a concrete, checkable set of signals you can apply to your own team and product right now.
18.1 Signals that favour staying with a monolith
Stay With a Monolith When…
- Your team has fewer than roughly 10–15 engineers.
- You are still finding product‑market fit and requirements change weekly.
- Your business‑domain boundaries are unclear or still evolving.
- Traffic is fairly uniform across features — no single feature needs 100× more capacity than another.
- You need strong transactional consistency across most operations.
- You do not yet have a dedicated platform/DevOps/SRE team.
- Deployment speed and iteration speed matter more than fine‑grained independent scaling right now.
- Your cloud/infrastructure budget is limited.
Consider Splitting Out Services When…
- You have many independent teams (10+) regularly blocking each other’s releases.
- Specific, well‑understood business capabilities have wildly different scaling needs.
- You have measured, repeated production incidents where one module’s failure takes down unrelated functionality.
- Domain boundaries are stable, well‑documented, and rarely change.
- You already have mature CI/CD, observability, and on‑call practices.
- Different parts of the system genuinely benefit from different technology stacks.
- You have dedicated platform‑engineering capacity to build and maintain the extra infrastructure.
18.2 A step‑by‑step decision process
18.3 A worked example
Scenario: a 12‑person startup building a project‑management tool has 5,000 active users. Traffic is steady, no single feature dominates load, the team ships daily, and they have no dedicated DevOps engineer.
Applying the framework: team size is small (12 < 15). Domain boundaries (tasks, projects, comments) are still evolving as they learn what customers want. There is no evidence of uneven scaling needs. They lack dedicated platform‑engineering capacity.
Recommendation: stay with a modular monolith. Organise the code into clean modules (tasks, projects, comments, billing) with enforced boundaries, invest in good tests and monitoring, and revisit this decision again once they hit a concrete, measured pain point — not before.
FAQ, Summary & Key Takeaways
The most common questions, answered plainly, followed by the short version of everything above.
Q: Does staying with a monolith mean my architecture is “bad” or “outdated”?
No. A well‑structured, modular monolith is considered excellent, modern architecture by senior engineers today, especially for small‑to‑medium teams and products. The idea that “real” companies must use microservices has been widely corrected across the industry.
Q: Can a monolith really handle “web‑scale” traffic?
Yes. Through horizontal scaling (many identical instances), caching, database read replicas, and good indexing, monoliths have served enormous, real‑world traffic volumes — Stack Overflow and early Shopify are well‑documented examples.
Q: What is the safest way to eventually move away from a monolith, if needed?
Modularise the monolith first with clean internal boundaries, gather real evidence of where scaling or team‑friction pain actually occurs, and then use the Strangler Fig pattern to extract only those specific, well‑understood pieces incrementally — never all at once.
Q: Is a “modular monolith” just a monolith with extra steps?
It is a monolith with real internal discipline — enforced module boundaries, clear ownership, and clean interfaces — so that the codebase stays maintainable as it grows, and could be split later if truly needed, without redesigning everything from scratch.
Q: What is the single biggest red flag that a team split too early?
A “distributed monolith” — many small services that must still all be deployed together and share a database — which delivers all the network overhead of microservices with none of the independence benefits.
Summary
A monolith is a single deployable application; microservices split an application into many independently deployable services communicating over a network. Microservices solve real problems — independent scaling, independent deployment, fault isolation, and technology diversity — but only at a cost: significant operational complexity, harder data consistency, higher latency, and a much bigger surface area to secure and monitor.
The right decision is not about which architecture is more modern or more prestigious — it is about matching your architecture to your team size, your domain clarity, your traffic pattern, and your operational maturity. For the large majority of products — especially small teams, evolving domains, and steady traffic — staying with a well‑structured, modular monolith is the correct, evidence‑based, senior‑engineer recommendation, and can be revisited later using real data rather than assumptions.
Key Takeaways
- Choose architecture based on measured team, traffic, and domain signals — never on trend or hype.
- A modular monolith gives you most of microservices’ organisational clarity without its network and consistency costs.
- Microservices’ biggest true advantage — independent scaling — only matters once load is genuinely uneven and large.
- The “distributed monolith” anti‑pattern is worse than either a clean monolith or clean microservices — avoid splitting without real service independence.
- Use the Strangler Fig pattern to extract services incrementally and safely, only once you have real evidence.
- Revisit the decision periodically with real data — the right answer can change as your team and product grow.
The architect’s case for the monolith is not that it is always the right choice. It is that it is a first‑class, defensible, evidence‑based choice — and often the more sophisticated one — whenever the specific signals that justify microservices are not yet present. When you can articulate those signals precisely, and honestly measure whether your own product exhibits them, you are already thinking like the senior architect this tutorial set out to help you become.