Monolith or Microservices: How to Choose Right
1. Overview
“Should we choose monolith or microservices?” — this question pops up in almost every system design session. This article doesn’t provide a one-size-fits-all answer. Instead, we’ll go through specific criteria: team size, release velocity, operational complexity, and communication costs between services. By the end, you’ll have a checklist to evaluate where your system should sit on the “axis” between monolith, modular monolith, and microservices.
2. Monolith: Simple but Often Underestimated
A monolith means all business logic (Order, Customer, Payment, Inventory…) runs in a single process, a single codebase, deployed all at once.
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
Order order = orderService.create(request);
return ResponseEntity.ok(OrderResponse.from(order));
}
}
@Service
public class OrderService {
private final InventoryService inventoryService;
private final PaymentService paymentService;
public OrderService(InventoryService inventoryService, PaymentService paymentService) {
this.inventoryService = inventoryService;
this.paymentService = paymentService;
}
public Order create(OrderRequest request) {
inventoryService.reserve(request.productId(), request.quantity());
paymentService.charge(request.customerId(), request.amount());
return new Order(request); // assume constructor saves to DB
}
}
OrderService calls InventoryService and PaymentService directly via method calls in the same JVM. No network, no serialization, and transactions can be wrapped in a single @Transactional. This is monolith’s biggest advantage: consistency is easy to guarantee, debugging is straightforward, and deployment is a one-shot affair.
Drawbacks emerge as the team grows: everyone modifying the same codebase, build times increase, and deploying a small change risks rebuilding the entire app.
Note: Monolith doesn’t mean “messy code.” A well-written monolith with clear modules (packages organized by domain, no circular dependencies) still scales well for systems serving millions of users.
3. Microservices: Separate to Scale Independently, Trade-off Operational Complexity
Microservices split Order, Inventory, Payment into separate services that communicate over the network (REST, gRPC, message queues).
@Service
public class OrderService {
private final RestClient restClient;
public OrderService(RestClient restClient) {
this.restClient = restClient;
}
public Order create(OrderRequest request) {
// Call Inventory Service via HTTP
restClient.post()
.uri("http://inventory-service/reservations")
.body(new ReservationRequest(request.productId(), request.quantity()))
.retrieve()
.toBodilessEntity();
// Call Payment Service via HTTP
restClient.post()
.uri("http://payment-service/charges")
.body(new ChargeRequest(request.customerId(), request.amount()))
.retrieve()
.toBodilessEntity();
return new Order(request);
}
}
Compared to the monolith in section 2, the key difference is in restClient.post(...): in-process method calls become HTTP calls over the network. Consequences:
- Each call can fail due to network timeouts, service crashes, DNS errors — failures that don’t exist in a monolith.
- No cross-service ACID transactions. You must handle this via saga pattern or eventual consistency.
- You need additional service discovery, load balancers, and distributed tracing to debug failures.
3.1. Hidden Cost: Distributed Transactions
@Service
public class OrderSagaOrchestrator {
public void createOrder(OrderRequest request) {
try {
inventoryClient.reserve(request.productId(), request.quantity());
paymentClient.charge(request.customerId(), request.amount());
} catch (PaymentFailedException e) {
// Must manually write compensation logic to rollback
inventoryClient.release(request.productId(), request.quantity());
throw e;
}
}
}
In a monolith, rollback is automatic with @Transactional. In microservices, you must manually write compensation logic (release after a failed reserve). This code doesn’t exist in a monolith but is mandatory in microservices to prevent data inconsistency.
4. Decision Criteria: Don’t Just Copy What Big Companies Do
4.1. Team Size and Conway’s Law
Conway’s Law: system structure mirrors organizational structure. If you have 5 developers and split into 5 services, each person owns one service — no one reviews anyone else’s code, and you lose the benefit of “multiple eyes on a bug.”
Practical thresholds:
1–15 dev, 1 team -> Monolith (modular)
15–50 dev, 2–5 teams -> Modular monolith or 2-3 large services
50+ dev, multiple teams -> Microservices by bounded context
4.2. Is Release Velocity Actually Bottlenecked?
If your team releases the monolith every 2 weeks with no serious merge conflicts, splitting services for “independent releases” doesn’t add value — it only adds operational cost (separate CI/CD, separate monitoring, API versioning).
Microservices pay off when: Team A needs to deploy 10 times/day for Payment features, while Team B deploys Inventory only once/week. Splitting lets A move independently without waiting for B.
4.3. Infrastructure and Operational Maturity
# docker-compose.yml - minimal infrastructure complexity for microservices
services:
order-service:
image: order-service:1.0
depends_on:
- inventory-service
- payment-service
- service-registry
inventory-service:
image: inventory-service:1.0
payment-service:
image: payment-service:1.0
service-registry:
image: eureka-server:1.0
api-gateway:
image: spring-cloud-gateway:1.0
With just 3 business services, you already need service-registry and api-gateway. Scale that to 20 real services, and the number of moving parts grows exponentially — requiring a strong DevOps team to manage Kubernetes, distributed logging (ELK), and tracing (Jaeger/Zipkin).
Note: If your company lacks expertise in container orchestration and observability stacks, microservices will turn every incident into a “needle in a haystack” debugging session across multiple service logs.
5. Modular Monolith: The Overlooked Middle Ground
A modular monolith is still a monolith, but code is organized into independent modules that communicate through clear interfaces and don’t share database tables.
// Package: com.nonladev.shop.order
public interface InventoryPort {
void reserve(String productId, int quantity);
}
// Package: com.nonladev.shop.inventory
@Service
public class InventoryService implements InventoryPort {
@Override
public void reserve(String productId, int quantity) {
// business logic, accesses inventory module's own DB
}
}
OrderService depends only on the InventoryPort interface, not on the inventory module’s implementation. Later, if you need to split inventory into its own service, you just swap the InventoryPort implementation with an HTTP client — the code calling it in OrderService stays the same.
This is the “start with monolith, split gradually when needed” strategy that many large systems (Shopify, GitHub in their early days) have successfully applied.
6. Test: Measuring Latency Cost When Switching from In-Process to Network Calls
The test below illustrates latency differences — a factor worth measuring in reality before deciding to split services.
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
class OrderServiceLatencyTest {
@Test
void inProcessCallShouldBeFasterThanRemoteCall() {
InventoryPort inProcess = (productId, qty) -> { /* no-op */ };
long startInProcess = System.nanoTime();
inProcess.reserve("P-1", 1);
long inProcessDuration = System.nanoTime() - startInProcess;
InventoryPort remoteSimulated = (productId, qty) -> {
try {
Thread.sleep(20); // simulate network round-trip ~20ms
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
long startRemote = System.nanoTime();
remoteSimulated.reserve("P-1", 1);
long remoteDuration = System.nanoTime() - startRemote;
assertThat(remoteDuration).isGreaterThan(inProcessDuration);
}
}
This test doesn’t measure your real infrastructure performance, but it illustrates the essence: each network call from Order to Inventory adds several to several tens of milliseconds. If a request chains through 5 services, accumulated latency can breach your SLA — a cost often overlooked when sketching architecture on paper.
7. When NOT to Choose Microservices
- Teams smaller than 10: operational costs (CI/CD, monitoring, on-call) for multiple services exceed the benefits of separation.
- Unstable domain: if business logic keeps changing (startup searching for product-market fit), service boundaries will be wrong repeatedly — split, then merge again — doubling the work.
- No observability: without distributed tracing and centralized logging, debugging cross-service failures is nearly impossible.
- High strong consistency requirements (e.g., core banking): keeping transactions in one boundary (monolith) is safer than implementing complex sagas.
Conversely, microservices are worth investing in when: multiple independent teams, each part needs separate scaling (e.g., search service needs more CPU than order service), or each domain needs different tech stacks (Node.js for real-time chat, Java for billing).
8. Conclusion
There’s no “absolutely right” architecture — only architecture that fits your current team size, release velocity, and operational capabilities. When in doubt, start with a modular monolith: maintain clear interfaces between domains, avoid cross-database dependencies, so when you truly need to split (for scale or team growth), you only swap implementations instead of rewriting the entire system. Microservices are tools for solving organizational and scaling problems, not architectural goals in themselves.