A Practical Guide to Microservice Architecture
Microservice architecture splits a single application into a set of small, independently deployable services, each owning one slice of the business and talking to the others over the network. It is a powerful way to scale teams and systems — and an excellent way to create distributed chaos if you adopt it for the wrong reasons. This guide walks through the patterns, the trade-offs, and the practical decisions that separate a clean microservice system from a distributed monolith.
From a monolith to microservices
A monolith packages every concern — UI, business logic, data access — into one deployable unit backed by one database. That is not an insult; for most systems it is the correct starting point. Problems appear at scale: a one-line change forces a full redeploy, a memory leak in one feature takes down all of them, and a hundred engineers contend for the same codebase and release train.
Microservices attack that coupling by decomposing the system along business capabilities. Each service is deployed on its own schedule, scaled independently, and — crucially — owns its own data.
What actually changes
The shift is less about code size and more about boundaries. In a monolith, calling another module is a method call: fast, transactional, and type-checked by the compiler. Across services it becomes a network call that can be slow, can fail, and is only as reliable as your contracts. A method like orderService.reserve(orderId) becomes an HTTP or message exchange with retries, timeouts, and serialization. You trade compile-time guarantees for deployment independence — make sure that trade is worth it.
When microservices earn their keep
Microservices are an organizational solution as much as a technical one. They pay off when independent teams need to ship independently, when parts of the system have very different scaling or availability needs, or when the codebase has grown beyond what one team can reason about. They cost you in network latency, eventual consistency, and operational overhead.
| Concern | Monolith | Microservices |
|---|---|---|
| Deployment | One artifact, all-or-nothing | Per-service, independent |
| Scaling | Scale the whole app | Scale hot services only |
| Data | Shared database, ACID | Database per service, eventual consistency |
| Failure isolation | One fault can sink everything | Contained to a service (if designed well) |
| Tech diversity | One stack | Right tool per service |
| Operational complexity | Low | High — needs real platform investment |
If your team is small and the domain is still shifting, a well-structured monolith will outrun microservices every time. Reach for services when the boundaries are stable and the pain is real.
Designing service boundaries
Bounded contexts
The hardest part of microservices is deciding where to cut. The most reliable guide is Domain-Driven Design's bounded context: group the things that change together for the same business reason. An Orders service, a Payments service, and an Inventory service each map to a distinct capability with its own language and rules. Cutting along technical layers instead (a "database service", a "logic service") just spreads one transaction across the network.
The shared-database trap
The single most common mistake is letting two services read and write the same tables. The moment they do, you can no longer deploy or evolve them independently — you are back to a monolith with extra latency. Each service must be the sole owner of its schema. If Payments needs order data, it asks the Orders service or subscribes to its events; it never runs SELECT against the orders table directly.
-- Anti-pattern: Payments reaching into the Orders schemaSELECT o.id, o.total, o.customer_idFROM orders oWHERE o.status = 'PENDING_PAYMENT';-- If you can write this query from another service, your boundary has leaked.Communication between services
Synchronous: REST and gRPC
Synchronous calls are simple to reason about: the caller waits for a reply. They are a good fit for queries and for request/response flows where the client genuinely needs an answer now. Keep them shallow — a chain of five synchronous hops multiplies latency and failure probability.
# Fetch a single order through the gatewaycurl -s https://api.shop.example/orders/8a3f2c \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/json"@RestController@RequestMapping("/orders")class OrderController { private final OrderService orders; OrderController(OrderService orders) { this.orders = orders; } @GetMapping("/{id}") ResponseEntity<OrderView> get(@PathVariable String id) { return orders.find(id) .map(OrderView::from) .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); }}Asynchronous: events and messaging
For everything else, prefer asynchronous messaging. The producer emits an event and moves on; interested services react in their own time. This decouples services in time (the consumer can be down) and in knowledge (the producer does not know who listens). The cost is eventual consistency — the rest of the system catches up a moment later.
{ "eventType": "OrderPlaced", "version": 1, "orderId": "8a3f2c", "customerId": "c-4471", "totalCents": 5980, "currency": "EUR", "occurredAt": "2026-06-19T10:24:11Z"}A good event is a fact about the past ("OrderPlaced"), not a command ("ChargeCard"). Name them in the past tense and treat them as an immutable, versioned contract — consumers depend on that shape.
Data management
Database per service
Each service keeps its own datastore, and it is free to choose the right one — relational for orders, a document store for catalogs, a search index for full-text. The catch: you lose cross-service ACID transactions. There is no BEGIN; ... COMMIT; that spans Orders and Payments. You need a different model for consistency.
The saga pattern
A saga models a business transaction as a sequence of local transactions, one per service, coordinated by events or by an orchestrator. If a step fails, the saga runs compensating transactions to undo the work already done — releasing reserved stock, refunding a charge — instead of a single rollback.
// Orchestrated saga: drive each step, compensate on failurepublic void placeOrder(OrderRequest req) { var orderId = orders.create(req); // step 1 try { inventory.reserve(orderId, req.items()); // step 2 payments.charge(orderId, req.total()); // step 3 shipping.schedule(orderId); // step 4 } catch (StepFailedException e) { // Compensate in reverse: undo what already succeeded payments.refundIfCharged(orderId); inventory.releaseIfReserved(orderId); orders.markFailed(orderId, e.getReason()); }}The API gateway
Clients should not call a dozen services directly. An API gateway gives them one entry point and handles the cross-cutting concerns: TLS termination, authentication, rate limiting, and routing. Behind it, a service registry lets services find each other as instances come and go.
Resilience
In a distributed system, failure is not an exception — it is the steady state. Something is always slow, restarting, or unreachable. Resilient services assume their dependencies will fail and degrade gracefully instead of cascading.
Circuit breakers
A circuit breaker watches calls to a dependency. After too many failures it "opens" and fails fast for a cooldown period, sparing both the caller and the struggling dependency, then probes to see if it has recovered. With a library like Resilience4j it is a single annotation plus a fallback.
@CircuitBreaker(name = "payments", fallbackMethod = "queueForLater")public PaymentResult charge(String orderId, Money amount) { return paymentsClient.charge(orderId, amount);}// Called when the breaker is open or the call failsprivate PaymentResult queueForLater(String orderId, Money amount, Throwable t) { outbox.save(new PendingCharge(orderId, amount)); return PaymentResult.deferred();}Timeouts and retries
Every network call needs a timeout — an unbounded wait is how one slow service exhausts the caller's threads and takes it down too. Pair timeouts with bounded, jittered retries for transient faults, and a bulkhead to isolate resource pools. These patterns compose:
| Pattern | Problem it solves | Typical tool |
|---|---|---|
| Timeout | Slow dependency blocks the caller | Client config |
| Retry + backoff | Transient, recoverable faults | Resilience4j, Polly |
| Circuit breaker | Cascading failure from a sick dependency | Resilience4j |
| Bulkhead | One dependency starves shared resources | Thread/connection pools |
| Rate limit | Overload and abuse | API gateway |
Observability
You cannot debug what you cannot see, and in a microservice system a single user action fans out across many services. Observability rests on three pillars, tied together by a traceId propagated on every call.
| Pillar | Question it answers | Example tools |
|---|---|---|
| Logs | What exactly happened in this request? | Loki, ELK |
| Metrics | How is the system behaving over time? | Prometheus, Grafana |
| Traces | Where did the time and the error go? | OpenTelemetry, Jaeger |
Deployment and infrastructure
Microservices only work if deploying one is cheap and routine. That means containers, a scheduler, and automated pipelines. A typical Kubernetes deployment declares the desired state and lets the platform converge to it:
apiVersion: apps/v1kind: Deploymentmetadata: name: order-servicespec: replicas: 3 selector: matchLabels: app: order-service template: metadata: labels: app: order-service spec: containers: - name: order-service image: registry.shop.example/order-service:1.8.2 ports: - containerPort: 8080 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 resources: requests: cpu: "250m" memory: "256Mi"# Roll out a new version and watch it convergekubectl apply -f order-service.yamlkubectl rollout status deployment/order-serviceNote the readinessProbe: the platform only routes traffic to an instance once it reports healthy, which is what makes zero-downtime rollouts possible.
When not to use microservices
Be honest about the cost. If you have a small team, an unproven product, or an unclear domain, microservices will slow you down — you will pay the full operational tax before you see any benefit. The pragmatic path is a modular monolith: clean internal boundaries, one deployable, one database. When a module's boundary proves stable and its scaling or team pressure becomes real, extract it into a service. Let the seams emerge before you cut along them.
Key takeaways
- Microservices buy independent deployment and scaling at the price of distributed-systems complexity.
- Cut boundaries along bounded contexts, and give every service sole ownership of its data.
- Prefer asynchronous events; use synchronous calls sparingly and keep chains shallow.
- Replace cross-service transactions with sagas and compensating actions.
- Assume failure: timeouts, retries, circuit breakers, and real observability are not optional.
- Start with a modular monolith and extract services only when the pain is real and the boundary is stable.
Comments (0)
Join the discussion