The most surprising thing about microservices is that they don’t actually make your system simpler; they just move the complexity from within a single, giant monolith to the interactions between many small services.
Let’s see this in action. Imagine we have a simple e-commerce checkout process. In a monolith, this might look like a single CheckoutService class with a processOrder() method that orchestrates everything: validating user info, checking inventory, processing payment, and sending confirmation emails.
public class MonolithicCheckoutService {
private UserValidator userValidator;
private InventoryService inventoryService;
private PaymentGateway paymentGateway;
private EmailService emailService;
public void processOrder(Order order) {
userValidator.validate(order.getUser());
inventoryService.reserveStock(order.getItems());
paymentGateway.charge(order.getPaymentInfo(), order.getTotal());
emailService.sendConfirmation(order.getUser(), order.getDetails());
System.out.println("Order processed successfully!");
}
}
Now, let’s break this down into microservices. We’ll have an OrderService, an InventoryService, a PaymentService, and a NotificationService. The OrderService will be responsible for initiating the checkout, but it needs to talk to the other services to get the job done.
Here’s a simplified look at the OrderService in a microservice architecture, using a hypothetical RPC (Remote Procedure Call) client:
// OrderService (simplified)
public class OrderMicroservice {
private InventoryServiceClient inventoryClient;
private PaymentServiceClient paymentClient;
private NotificationServiceClient notificationClient;
public void processOrder(Order order) {
// 1. Call InventoryService to reserve stock
inventoryClient.reserveStock(order.getItems());
// 2. Call PaymentService to process payment
PaymentResult paymentResult = paymentClient.processPayment(order.getPaymentInfo(), order.getTotal());
if (paymentResult.isSuccess()) {
// 3. If payment is successful, confirm order and notify
order.setStatus("CONFIRMED");
// In a real system, this would likely be persisted to a DB
System.out.println("Order " + order.getId() + " confirmed.");
// 4. Call NotificationService to send email
notificationClient.sendOrderConfirmation(order.getUser(), order.getDetails());
} else {
// Handle payment failure
order.setStatus("PAYMENT_FAILED");
System.err.println("Payment failed for order " + order.getId());
// Potentially call InventoryService to release reserved stock if it was reserved before payment
}
}
}
This OrderMicroservice now doesn’t know how inventory is managed or how payments are processed. It just knows it can ask another service to do it. This is the core idea: each microservice owns its domain and exposes well-defined APIs for others to interact with.
The problem this solves is the "big ball of mud" that monoliths often become. As a monolith grows, dependencies become tangled. A change in one part of the system can have unforeseen ripple effects across the entire application. Microservices aim to create clear boundaries. If the PaymentService needs to be updated (e.g., to integrate with a new payment provider), only that service needs to change. As long as its API contract remains the same, the OrderService (and any other service calling it) won’t even notice.
Internally, each microservice is typically a small, focused application. It has its own database, its own codebase, and its own deployment pipeline. This allows teams to work independently, choose the best technology for their specific problem, and deploy features much faster.
The exact levers you control are the APIs exposed by each service. These are the contracts. For example, the InventoryServiceClient might be defined by an interface like this:
public interface InventoryApi {
// Returns a reservation ID, or throws an exception if stock is unavailable
String reserveStock(List<Item> items) throws InsufficientStockException;
void releaseStock(String reservationId);
}
The OrderService uses this API. If the InventoryService implementation changes to, say, use a different internal data structure or a different database, as long as the reserveStock method still accepts a List<Item> and returns a String (or throws the expected exception), the OrderService remains unaffected. This principle is known as loose coupling.
The other major lever is data ownership. Each microservice should ideally own its own data. The InventoryService has its own inventory database. The PaymentService has its own transaction logs. This prevents the kind of shared database issues you see in monoliths where one team’s schema changes can break another team’s code.
One thing most people don’t know is that the "service" in microservice doesn’t strictly mean a separate process. While that’s the common pattern, you can have multiple "services" (logically distinct domains) within a single deployable unit (like a WAR file or a Docker container). The crucial part is the clear separation of concerns and well-defined communication interfaces, not necessarily the physical deployment boundary. This allows for a gradual transition from a monolith to microservices, where you can extract services one by one without immediately needing to manage hundreds of separate deployments.
The next concept you’ll run into is handling distributed transactions and ensuring data consistency across these independent services when a single business operation spans multiple of them.