Microservices Architecture: A Practical Guide
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
paymentIdfield"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
The distributed monolith. Services that must be deployed together, or share a database. You get all the network pain and none of the independence.
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.
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.
No timeouts or circuit breakers. One slow dependency quietly takes everything down with it.
Ignoring data consistency. Dual writes without an outbox, and retries without idempotency, lead to lost events and duplicate payments.
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.
Comments
Join the discussionNo comments yet. Be the first to comment.