The biggest surprise about monoliths is that they’re often the fastest way to get something working, even if they eventually become the slowest to change.
Imagine a single, massive application where all the code for user interfaces, business logic, and data access lives in one place. This is the monolith. When you first build it, everything is tightly coupled, meaning changes in one part directly impact others. This tight coupling, while a pain later, makes initial development rapid because you don’t have to worry about coordinating multiple services.
Let’s see this in action. Consider a simple e-commerce application.
// src/main/java/com/example/ecommerce/ProductService.java
public class ProductService {
private final ProductRepository productRepository;
private final InventoryService inventoryService; // Tightly coupled
public ProductService(ProductRepository productRepository, InventoryService inventoryService) {
this.productRepository = productRepository;
this.inventoryService = inventoryService;
}
public Product getProductDetails(Long productId) {
Product product = productRepository.findById(productId);
int stock = inventoryService.getStockCount(productId); // Direct call
product.setStock(stock);
return product;
}
}
// src/main/java/com/example/ecommerce/InventoryService.java
public class InventoryService {
// ... inventory logic ...
public int getStockCount(Long productId) {
// ... fetch from database ...
return 50; // Example stock
}
}
In this snippet, ProductService directly calls InventoryService. If InventoryService changes its method signature or how it retrieves stock, ProductService must be updated. This is the essence of a monolith: one deployment unit, one codebase, one build process.
The primary advantage is simplicity in development and deployment. When you start, you have a single codebase to manage, a single build pipeline, and a single artifact (like a WAR or JAR file) to deploy. Debugging can also be simpler initially because you’re tracing execution within a single process.
The mental model for a monolith is straightforward: it’s a single executable unit. All modules, libraries, and dependencies are packaged together. Think of it like a large house where all rooms are under one roof and share the same foundation and utilities.
However, this simplicity is a double-edged sword. As the application grows, the tight coupling becomes a significant impediment.
- Scalability Issues: You can’t scale individual components. If your product catalog is popular but inventory checks are rarely needed, you still have to scale the entire application. This is inefficient.
- Technology Lock-in: It’s difficult to adopt new technologies. If you want to use a different database for a specific feature, you can’t easily do it without impacting the entire monolith.
- Deployment Risks: A small change requires redeploying the entire application, increasing the risk of introducing bugs or downtime.
- Maintainability Challenges: The codebase can become a tangled mess, making it hard for new developers to understand and contribute.
The core problem monoliths solve is reducing the initial overhead of distributed systems. You avoid the complexities of inter-service communication, distributed transactions, and managing multiple deployment pipelines. The "how it works internally" is simply a series of function calls within a single process. The "levers you control" are typically configuration settings related to the application’s behavior and the underlying infrastructure (like web server threads or database connection pools).
When you’re refactoring a large monolith, it’s tempting to just move code around within the same project. However, the real power comes from identifying distinct functional areas and breaking them out. For instance, if your InventoryService logic is complex and has its own data needs, it might be a candidate for its own microservice. The transition involves creating a new, independent service that exposes its functionality via an API (like REST or gRPC), and then modifying the monolith to call this API instead of executing the code directly. This shifts the problem from "how do I change this code without breaking everything else?" to "how do I communicate with this other service?"
The next challenge you’ll face after successfully breaking out a piece of functionality is handling eventual consistency across services.