Skip to main content

Microservices Architecture: A Practical Guide

Updated 14 min readMarkdown

Microservices don't make your system simpler. They move the complexity from your codebase onto your network.

That trade can be well worth it. It can also sink a team that wasn't ready for it. In this guide I'll cover what microservices are, the different types, when they make sense, the building blocks every system needs, how to handle transactions across services, and the mistakes I see most often. I'll keep it practical, with Java and Spring Boot examples where they help.

What microservices are

A microservice is a small service that owns one business capability end to end: its code, its data and its deployment. An e-commerce system might have separate Order, Payment, Inventory and Notification services, each talking to the others over HTTP or messages.

A monolith puts all of that in one deployable unit with one shared database. That isn't a bad thing. Most successful products started as one.

Monolith Microservices
Deployment One unit, deployed together Each service deployed on its own
Database One shared database One database per service
Scaling Scale the whole app Scale only the hot services
Calls between modules In-process method calls Network calls (can fail, add latency)
Team model Teams share one codebase Each team owns its services
Debugging One stack trace Requests span many services
Consistency ACID transactions Eventual consistency, sagas

The key idea is independence. If you can't change and deploy a service without coordinating with three other teams, you don't have microservices. You have a distributed monolith.

Types of microservices

Not every service does the same job. Knowing the types helps you decide where logic belongs and keeps services from turning into a tangle of calls.

By role

Type What it does Example
Domain (business) service Owns one business capability and its data. Most of your services are this type. Order service, Payment service
Aggregator (composite) service Calls several services and combines their results into one response An "order details" service that merges order, payment and shipping info
Backend for Frontend (BFF) An aggregator built for one client type, shaping data for that UI Separate BFFs for the mobile app and the web app
Edge / gateway service The entry point for outside traffic: routing, auth, rate limiting Spring Cloud Gateway, Kong
Integration (adapter) service Wraps a third-party or legacy system behind a clean API A wrapper around a payment provider or an old mainframe
Infrastructure (utility) service Shared technical functions with no business logic Notification, auth/identity, file storage

By state

  • Stateless services keep no data between requests. Any instance can handle any request, so they're easy to scale and replace. Aim for this wherever you can.

  • Stateful services hold data or session state, like a database-backed service or a stream processor. They need more care to scale, back up and recover. Keep state in a database or cache rather than in the service's memory where you can.

A useful rule: business logic lives in domain services. Aggregators, BFFs and gateways should combine and shape data, not make business decisions.

When to use them, and when not to

Microservices solve organisational and scaling problems. If you don't have those problems yet, you're paying the cost without getting the benefit.

They make sense when:

  • Several teams are stepping on each other in one codebase and releases keep getting blocked

  • Parts of the system have very different load, like a checkout that spikes at sale time while admin pages stay quiet

  • Parts need different tech or release cadences

  • You need fault isolation, so one failing feature doesn't take the whole product down

Stick with a monolith when:

  • You're a small team or an early product still finding its domain boundaries

  • You don't yet have CI/CD, containers, monitoring and on-call in place

  • Your "services" would all share one database anyway

A good middle path is the modular monolith: one deployable, with strict module boundaries inside it. If a module later needs to scale or move faster on its own, you can pull it out cleanly. Cutting along boundaries you've already proven is far easier than guessing them up front.

The core building blocks

Every microservices system ends up needing the same handful of pieces. Here they are, and what each one is for.

Requests come in through the gateway, services call each other directly or through events, and no service touches another's database.

API gateway. The single entry point for clients. It handles routing, authentication, rate limiting and TLS, so each service doesn't have to. Examples: Spring Cloud Gateway, Kong, AWS API Gateway.

Service discovery. Service instances come and go as they scale or restart, so hard-coded addresses break. A registry tracks where each one lives. Examples: Eureka, Consul, or Kubernetes DNS.

Centralised configuration. Keep config outside the code so you can change it per environment without a rebuild. Examples: Spring Cloud Config, Kubernetes ConfigMaps and Secrets.

Communication. Services talk in two ways:

  • Synchronous (REST, gRPC): the caller waits for an answer. Simple, but it couples services in time. If Payment is down, Order is stuck.

  • Asynchronous (Kafka, RabbitMQ, SQS): the caller publishes an event and moves on. More resilient and loosely coupled, but harder to trace and reason about.

A common rule of thumb: use synchronous calls for queries that need an answer now, and events for things that "have happened" and others should react to.

Resilience: design for failure

In a monolith, a method call doesn't time out. In microservices, every network call can be slow, fail, or succeed without you hearing back. Assume it will happen, and design for it.

Pattern What it does Why it matters
Timeouts Caps how long you wait for a response Without one, a slow service ties up your threads until you fall over too
Retries with backoff and jitter Retries transient failures, waiting longer each time with some randomness Instant retries from many clients cause a retry storm that keeps a struggling service down
Circuit breaker Stops calling a failing service for a while and fails fast instead Gives the failing service room to recover and stops failures cascading
Bulkhead Gives each dependency its own thread or connection pool One slow dependency can't use up resources the others need
Fallback Returns a cached or default response when a call fails The user gets a degraded experience rather than an error page

In the Spring world, Resilience4j gives you all of these as annotations, like @CircuitBreaker, @Retry and @Bulkhead.

One warning: only retry operations that are safe to run twice. For anything else, like creating a payment, you need idempotency keys. More on that next.

Data: the hardest part

The golden rule is database per service. Each service owns its data, and other services can only get to it through that service's API or events. If two services read and write the same tables, they're coupled at the schema level and can't be changed or deployed on their own.

The cost is that you lose cross-service ACID transactions. Two patterns keep individual writes safe. The next section covers transactions that span several services.

1. Idempotency keys: make retries safe. The client sends a unique key with each operation. The server stores the key with the result, and if the same key comes in again it returns the saved result instead of running the operation twice. This is how payment APIs like Stripe's avoid double charges.

2. Transactional outbox: never lose an event. Saving to your DB and publishing to Kafka are two separate writes, and either one can fail. With an outbox, you write the event to an outbox table in the same DB transaction as your business data. A relay (polling, or CDC with Debezium) then publishes it to the broker. Either both are saved, or neither is.

These two work together. The outbox relay may deliver an event more than once, so every consumer should be idempotent.

Transaction handling across services

In a monolith, placing an order is one ACID transaction: either everything commits or everything rolls back. In microservices, the same order touches the Order, Payment and Inventory databases, and no single transaction can cover all three. You trade strict consistency for eventual consistency, and you handle failure explicitly.

Why not two-phase commit (2PC)?

2PC uses a coordinator that asks every participant to "prepare", then tells them all to "commit". It gives you atomicity, but it rarely fits microservices:

  • Participants hold locks while they wait, so one slow service blocks the others

  • The coordinator is a single point of failure: if it dies mid-commit, participants can be stuck

  • Many modern stores and brokers (Kafka, most NoSQL databases) don't support XA transactions

  • It couples services tightly in time, which is what microservices try to avoid

The saga pattern

A saga splits one business transaction into a series of local transactions, one per service. Each step commits on its own and triggers the next. If a step fails, the saga runs compensating transactions to undo the steps that already committed.

Step Service Action Compensation if a later step fails
1 Order Create order as PENDING Mark order CANCELLED
2 Payment Charge the customer Refund the payment
3 Inventory Reserve stock Release the stock
4 Order Mark order CONFIRMED (last step, nothing to undo)

The payment already committed, so it can't be rolled back. The saga issues a refund instead, then cancels the order.

There are two ways to coordinate a saga:

Choreography Orchestration
How it works Each service listens for events and publishes the next one A central orchestrator sends commands to each service and tracks state
Coupling Loose, no central component Services depend on the orchestrator
Visibility The flow is spread across services and hard to follow The whole flow lives in one place
Best for Short sagas with 2–4 steps Longer or branching flows
Tools Kafka, RabbitMQ events Temporal, Camunda, Axon, Eventuate Tram

Designing good compensations

  • A compensation is a new transaction, not a rollback. You don't delete the payment record. You issue a refund, so the history stays accurate.

  • Some actions can't be undone, like sending an email or shipping a parcel. Put them as late as possible in the saga, after the step that decides whether the saga will succeed.

  • Compensations must be idempotent and retriable. If a refund command is delivered twice, the customer should still be refunded only once.

Handling isolation

Sagas give you atomicity in the end, but not isolation. While a saga is running, other requests can see half-finished state, like an order that's charged but not yet confirmed. Common ways to handle it:

  • Semantic locks: a status like PENDING tells other requests the record is mid-saga, so they wait or reject

  • Optimistic locking: a version number on each record so conflicting updates are detected, not silently overwritten

  • Commutative updates: design operations so their order doesn't matter, like "add 10 credits" rather than "set balance to 110"

  • Honest UIs: show "Your order is being processed" rather than pretending it's instant

A lighter alternative: TCC

Try-Confirm/Cancel works like a business-level 2PC. Each service first tries by reserving resources (holding stock, putting a hold on a card). If every try succeeds, all services confirm. Otherwise they cancel and release the holds. It suits short flows where reserving resources is natural, like bookings and payments.

Observability and deployment

When one user request touches six services, "check the logs" stops working. You need three things, often called the three pillars of observability:

  • Centralised, structured logs: every service logs JSON to one place (ELK, Loki, CloudWatch), tagged with a correlation ID so you can follow one request across services.

  • Metrics: latency, error rate and throughput per service (Micrometer + Prometheus + Grafana). Alert on what users feel, not on CPU.

  • Distributed tracing: a trace shows the full path of a request and how long each hop took (OpenTelemetry, Jaeger, Zipkin). It's the fastest way to find which service is slow.

On the deployment side, independence only pays off if releasing is cheap:

  • Containers (Docker) so each service ships with its own runtime

  • An orchestrator (Kubernetes, ECS) for scaling, health checks and self-healing

  • A CI/CD pipeline per service with automated tests, so a team can ship without waiting on anyone

  • Safe rollouts like blue-green or canary releases, so a bad deploy hits a few users, not all of them

Contract testing

When services deploy on their own, the riskiest change is an API change. The Payment team renames a field, their tests pass, and the Order service breaks in production. End-to-end tests can catch this, but they're slow, flaky and need every service running.

A contract test checks the agreement between two services in isolation:

  • The consumer (Order service) writes down what it expects: "POST /payments with this body returns 201 and a paymentId field"

  • That expectation is saved as a contract

  • The provider (Payment service) runs the contract against its real code in its own CI pipeline. If a change breaks the contract, the provider's build fails before it ships

Two popular tools:

  • Pact: consumer-driven. Consumers publish contracts to a Pact Broker, and providers verify against them. Works across languages.

  • Spring Cloud Contract: the provider writes contracts in Groovy or YAML. It generates tests for the provider and WireMock stubs that consumers can test against. A natural fit for Spring Boot teams.

Contract tests don't replace unit or integration tests. They fill the gap between them, so each team can deploy with confidence without spinning up the whole system.

Common pitfalls

  1. The distributed monolith. Services that must be deployed together, or share a database. You get all the network pain and none of the independence.

  2. Services that are too small. Splitting by technical layer or by entity (a "UserService", an "AddressService") creates chatty calls. Split by business capability instead, using domain-driven design's bounded contexts as a guide.

  3. Long synchronous call chains. A → B → C → D means any one failure fails the request, and the latencies add up. Use events or cache the data you need locally.

  4. No timeouts or circuit breakers. One slow dependency quietly takes everything down with it.

  5. Ignoring data consistency. Dual writes without an outbox, and retries without idempotency, lead to lost events and duplicate payments.

  6. Microservices before the platform. Without CI/CD, monitoring and tracing in place, every incident turns into a hunt across a dozen services.

Wrapping up

Microservices are a tool for scaling teams and systems, not a default. Start with clear boundaries, often inside a modular monolith. Split out a service when you have a real reason to. When you do, budget for the hard parts from day one: resilience, data consistency and observability.

If you're designing your first microservices system, here's a good order to work in: define the boundaries, give each service its own data, add timeouts and circuit breakers, make writes idempotent, then add tracing before you need it.

What's the hardest microservices problem you've faced in production? I'd love to hear about it in the comments.

40 views

No comments yet. Be the first to comment.