Skip to content

Microservices

A microservices architecture splits an application into small services, each responsible for one business capability (orders, payments, inventory) and each deployable on its own. Services own their data and talk to each other over the network through APIs or messages.

The goal is organizational as much as technical: let many teams change and release their part of the system without coordinating with everyone else.

flowchart LR
    client[Web and mobile clients] --> gw[API gateway]
    gw --> orders[Orders service]
    gw --> catalog[Catalog service]
    gw --> payments[Payments service]
    orders --> odb[(Orders DB)]
    catalog --> cdb[(Catalog DB)]
    payments --> pdb[(Payments DB)]
    orders -- OrderPlaced event --> bus{{Message broker}}
    bus --> payments
    bus --> notify[Notification service]
  • One capability per service. Boundaries follow business capabilities or domain-driven design bounded contexts, not technical layers.
  • Database per service. No other service reads a service’s tables directly. Data is shared only through its API or events.
  • Independent deployment. Each service has its own pipeline and can be released without redeploying the others.
  • Communication over the network. Synchronous calls (REST, gRPC) for queries that need an immediate answer, and asynchronous events for everything that can wait.
  • Edge routing. An API gateway handles routing, authentication and rate limiting for external clients.
  • Independent releases: teams ship on their own schedule.
  • Targeted scaling: scale only the services under load.
  • Fault isolation: with good design, one failing service degrades a feature instead of the whole application.
  • Technology freedom: each service can use the language or datastore that suits it.
  • Clear ownership: a team owns a service end to end.
  • Distributed-systems complexity: network failures, latency, retries and partial outages become everyday concerns.
  • Data consistency is harder: transactions across services need sagas or eventual consistency instead of a single database transaction.
  • Operational overhead: dozens of pipelines, deployments, dashboards and on-call rotations.
  • Harder debugging: a single request may cross many services, so distributed tracing becomes essential.
  • Testing across services: contract tests and integration environments are needed to catch breaking changes.
  • Several teams work on the same product and block each other’s releases.
  • Parts of the system have very different scaling or availability needs.
  • The domain is well understood, so service boundaries can be drawn with confidence.
  • The organization already has mature CI/CD, observability and platform automation.
  • A small team or an early-stage product whose domain is still changing. Boundaries drawn too early are expensive to move.
  • No automated deployment or monitoring in place yet.
  • The main motivation is “it’s modern” rather than a specific scaling or team problem.

In these cases, a modular monolith usually delivers most of the benefit at a fraction of the cost.

  • The distributed monolith. Services that must be deployed together, or that call each other in long synchronous chains, carry the costs of both styles and the benefits of neither.
  • Shared databases. Two services writing to the same tables are coupled, whatever the deployment diagram says.
  • Services that are too small. Splitting by entity (“customer-address-service”) instead of capability multiplies network calls for no gain.
  • Ignoring idempotency. Retries and redelivered messages will happen; handlers must tolerate duplicates. See idempotency.
  • Observability as an afterthought. Without correlation IDs and tracing from day one, production incidents become guesswork.

A production-shaped blueprint. Components are described generically first, then with one concrete example stack; the cloud mapping shows equivalents on each major provider.

An online ordering platform: customers browse a catalog, place orders and pay. Three teams own three services: Orders, Payments and Catalog. A Notifications service sends confirmations. Orders must never be charged twice, and a slow catalog must not stop customers from paying.

flowchart LR
    web[Web and mobile apps] --> gw[API gateway]
    gw --> orders[Orders service]
    gw --> catalog[Catalog service]
    orders -- sync: price check --> catalog
    orders --> odb[(Orders DB<br/>+ outbox table)]
    odb -. change data capture .-> relay[Outbox relay]
    relay --> broker{{Event broker}}
    broker --> payments[Payments service]
    payments --> pdb[(Payments DB<br/>+ processed events)]
    payments --> psp[Payment provider]
    payments -- PaymentCaptured --> broker
    broker --> orders
    broker --> notify[Notifications service]
    subgraph platform [Platform]
        obs[Tracing, metrics, logs]
        sec[Secrets manager]
    end
Component Responsibility Vendor-neutral choice Example stack
API gateway Routing, authentication, rate limiting Any API gateway Kong (opens in a new tab)
Services One business capability each Containerized services Java 21, Spring Boot (opens in a new tab) 3
Service databases Each service’s private data Relational database per service PostgreSQL (opens in a new tab)
Outbox relay Publish events written to the outbox table Change data capture Debezium (opens in a new tab)
Event broker Asynchronous events between services Durable log or message broker Apache Kafka (opens in a new tab)
Runtime Deploy, scale and heal services Container orchestrator Kubernetes (opens in a new tab)
Observability Traces, metrics, logs across services OpenTelemetry (opens in a new tab)-based stack OpenTelemetry, Prometheus (opens in a new tab), Grafana (opens in a new tab)
Secrets Credentials and keys Secrets manager HashiCorp Vault (opens in a new tab)
sequenceDiagram
    participant C as Customer
    participant G as API gateway
    participant O as Orders
    participant K as Kafka
    participant P as Payments
    participant X as Payment provider
    C->>G: POST /orders (Idempotency-Key)
    G->>O: forward with user identity
    O->>O: save order + OrderPlaced in outbox (one transaction)
    O-->>C: 202 Accepted, order PENDING
    O--)K: relay publishes OrderPlaced
    K--)P: OrderPlaced
    P->>P: skip if event already processed
    P->>X: capture payment (idempotency key = order id)
    P--)K: PaymentCaptured
    K--)O: PaymentCaptured
    O->>O: order status = CONFIRMED

The customer gets an immediate response; payment happens asynchronously, and every step can be safely retried.

1. Write the order and its event atomically (transactional outbox). The event is stored in the same database transaction as the order, so one can never exist without the other.

CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_type TEXT NOT NULL, -- e.g. 'Order'
aggregate_id TEXT NOT NULL, -- used as the message key
event_type TEXT NOT NULL, -- e.g. 'OrderPlaced'
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@Service
public class OrderService {
private final OrderRepository orders;
private final OutboxRepository outbox;
@Transactional
public Order placeOrder(PlaceOrderCommand cmd) {
Order order = orders.save(Order.pending(cmd));
outbox.save(OutboxEvent.of(
"Order", order.getId().toString(),
"OrderPlaced", OrderPlaced.from(order)));
return order; // both rows commit together, or neither does
}
}

A change-data-capture tool such as Debezium reads new outbox rows from the database log and publishes them to Kafka, keyed by aggregate_id so events for one order stay in order.

2. Make every consumer idempotent. Kafka delivers at least once, so the Payments service records each processed event ID in the same transaction as its own changes.

@KafkaListener(topics = "orders.order-placed", groupId = "payments")
@Transactional
public void onOrderPlaced(OrderPlaced event) {
int firstTime = jdbc.update(
"INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING",
event.eventId());
if (firstTime == 0) {
return; // duplicate delivery: already handled
}
payments.capture(event.orderId(), event.amount());
}

3. Contain slow dependencies. Calls between services get a timeout, limited retries and a circuit breaker, here with Resilience4j (opens in a new tab):

@CircuitBreaker(name = "catalog", fallbackMethod = "lastKnownPrice")
@Retry(name = "catalog")
public Price currentPrice(String sku) {
return catalogClient.price(sku); // HTTP client with a 500 ms timeout
}
private Price lastKnownPrice(String sku, Throwable cause) {
return priceCache.get(sku); // degrade instead of failing the order
}

4. Run each service with health checks, limits and autoscaling.

apiVersion: apps/v1
kind: Deployment
metadata:
name: orders
spec:
replicas: 3
selector:
matchLabels: { app: orders }
template:
metadata:
labels: { app: orders }
spec:
containers:
- name: orders
image: registry.example.com/orders:1.4.2
ports: [{ containerPort: 8080 }]
env:
- name: JAVA_TOOL_OPTIONS
value: "-javaagent:/otel/opentelemetry-javaagent.jar"
- name: OTEL_SERVICE_NAME
value: orders
resources:
requests: { cpu: "250m", memory: "512Mi" }
limits: { memory: "1Gi" }
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }

The OpenTelemetry Java agent adds distributed tracing without code changes, so one trace follows an order from the gateway through Kafka to Payments.

Component AWS (opens in a new tab) Azure (opens in a new tab) Google Cloud (opens in a new tab)
Kubernetes EKS (opens in a new tab) AKS (opens in a new tab) GKE (opens in a new tab)
API gateway Amazon API Gateway (opens in a new tab) Azure API Management (opens in a new tab) Apigee (opens in a new tab)
Event broker Amazon MSK (opens in a new tab) Event Hubs (opens in a new tab) (Kafka protocol) Pub/Sub (opens in a new tab), or Kafka on GKE
PostgreSQL Amazon RDS (opens in a new tab) / Aurora (opens in a new tab) Azure Database for PostgreSQL (opens in a new tab) Cloud SQL (opens in a new tab) / AlloyDB (opens in a new tab)
Tracing AWS X-Ray (opens in a new tab) Application Insights (opens in a new tab) Cloud Trace (opens in a new tab)
Secrets Secrets Manager (opens in a new tab) Key Vault (opens in a new tab) Secret Manager (opens in a new tab)

Service names change over time; check each provider’s current catalog.

The microservices reference implementation (opens in a new tab) builds the Orders and Payments services six times against one shared contract (opens in a new tab). It runs on a laptop with Docker Compose (opens in a new tab) and keeps the infrastructure minimal: a polling outbox relay instead of Debezium, and no gateway, Kubernetes or tracing.

Language Stack Source
Java 21 Spring Boot 4, JdbcTemplate, Spring Kafka (opens in a new tab), Resilience4j java (opens in a new tab)
Python 3.12 FastAPI (opens in a new tab), psycopg 3 (opens in a new tab), confluent-kafka (opens in a new tab), pybreaker (opens in a new tab) python (opens in a new tab)
Go 1.25 net/http, pgx (opens in a new tab), kafka-go (opens in a new tab), gobreaker (opens in a new tab) go (opens in a new tab)
Rust axum (opens in a new tab), sqlx (opens in a new tab), rdkafka (opens in a new tab), reqwest (opens in a new tab) rust (opens in a new tab)
C# (.NET (opens in a new tab) 10) ASP.NET Core (opens in a new tab) minimal APIs, Npgsql (opens in a new tab), Confluent.Kafka (opens in a new tab), Polly (opens in a new tab) dotnet (opens in a new tab)
TypeScript (Node (opens in a new tab) 22) Fastify (opens in a new tab), node-postgres (opens in a new tab), KafkaJS (opens in a new tab) node (opens in a new tab)
Terminal window
git clone https://github.com/architecture-compass/reference-implementations
cd reference-implementations/microservices
docker compose --profile go up -d --build # or: java, python, rust, dotnet, node
./demo.sh

The demo places an order, repeats it with the same idempotency key, follows it through the outbox, Kafka and Payments to CONFIRMED, redelivers a duplicate event, and stops the catalog to show the circuit breaker and price fallback. Every language passes all seven checks.

Want to go further? The README’s Break it yourself (opens in a new tab) section has hands-on experiments that show each pattern at work, including what happens when things fail.

  • Each service owns its database; no shared tables
  • Events published through an outbox, never directly after a commit
  • Every consumer is idempotent and has a dead-letter topic
  • Timeouts, retries and circuit breakers on every synchronous call
  • Traces propagate across HTTP calls and Kafka messages
  • Each service deploys independently through its own pipeline
  • Contract tests catch breaking API and event changes before release
  • Runbooks and alerts per service, owned by the team that runs it

Glossary: bounded context, eventual consistency, saga, idempotency

Written and edited by Abhinav Taduka, enterprise software architect.