Microservices Anti-Corruption Layer
Protect a service's domain model from a foreign or legacy model with a translation layer at the boundary.
Designing and running distributed services: boundaries, API gateways, synchronous vs async communication, resilience patterns, data consistency and observability.
Official reference: microservices.io patterns
Protect a service's domain model from a foreign or legacy model with a translation layer at the boundary.
Evolve service APIs without breaking consumers using additive changes, explicit versioning and consumer-driven contracts.
Use a per-client BFF to aggregate services and shape responses, without letting it become a shared god service.
Coordinate exclusive access across instances with leases and fencing, and prefer partitioning or idempotency when you can.
Store events as the source of truth, rebuild state by replay, and manage versioning with snapshots.
Separate liveness from readiness, keep deep dependency checks off the restart path, and avoid the restart loop.
Register event schemas centrally, enforce compatibility on publish, and version them so consumers are never surprised.
Move cross-cutting networking concerns into a co-located proxy, configure them centrally, and watch for hidden retries.
Replace a monolith incrementally by routing slices to new services, avoiding a big-bang rewrite.
What an API gateway should own (TLS, token checks, routing, rate limits) and why refund rules and workflows belong in the services behind it.
How a slow recommendations call starves checkout through a shared pool, and how to size separate bulkheads with Little's law and fast rejection.
Closed, open and half-open states traced through a runnable breaker, plus sizing, which errors to count and what callers do when it's open.
Treat config changes like deploys: validate before applying, roll out by stable user buckets or by instance, and keep a fast rollback.
Why at-least-once delivery hands a consumer the same event twice, and how a processed-message table in one transaction stops a double credit.
Which API and event changes are safe, why a rename breaks even tolerant readers, and how expand-and-contract lets a producer ship alone.
Why read-only access to another service's tables still couples you to its schema, and how APIs, events and local copies replace sharing.
Why per-hop timeouts keep services working after the client has left, and how passing a remaining-time budget down the chain stops it.
How trace and span ids travel in the W3C traceparent header, why a queue hop often splits a trace in two, and how sampling shapes what you see.
Why one order's events arrive out of sequence without a partition key, what a key does and doesn't guarantee, and how versions catch the rest.
How an Idempotency-Key header lets a client retry a payment POST safely: claim the key, fingerprint the body, replay the stored response.
Extracting one capability at a time from a monolith: cut the seam in-process, move the data owner, compare in a parallel run, then switch.
Why saving an order and then publishing its event can lose messages, and how an outbox row in the same transaction trades loss for duplicates.
Building query views from events, why a user can't see the order they just placed, and how position tokens and idempotent projectors help.
Fault-injection experiments with a hypothesis, steady-state metrics and abort conditions, and why injected latency finds bugs errors miss.
How three layers of retries turn one failing request into 64 database calls, and how a 10% retry budget caps extra load during outages.
Running an order saga across inventory and payments, ordering steps around the pivot, and what compensations can't undo: emails, fees, reads.
Drawing service boundaries around business capabilities, not entities or layers, with change history as evidence and signs a split is wrong.
How callers find instances whose addresses keep changing (registries, TTLs, caches, DNS) and why a crashed instance still gets traffic.
When a service should call another and wait, when it should publish an event instead, and what availability math and an email outage show.
Microservices is an architectural style where an application is built as a suite of small services. Each one runs in its own process, owns its own data, and talks to the others over lightweight mechanisms such as HTTP APIs or messaging. Each service is built around a business capability and can be deployed independently by the team that owns it.
A monolith is a single deployable unit: all modules share one codebase, one process and usually one database, and they call each other in-process.
The key differences:
Microservices trade simplicity for independent deployability and team autonomy, so they pay off mainly for larger systems and organizations.
Likely follow-up: How small should a microservice be?
Advantages
Disadvantages
It's a trade: you buy autonomy and scalability, and you pay for them in operational and design complexity.
I'd avoid them, or at least start with a well-structured monolith, when:
Martin Fowler calls this the "microservice premium": the extra cost is only worth paying once the system is complex enough that a monolith is hard to manage. A common path is monolith first, then extract services where there's a clear need.
Likely follow-up: What signals tell you it is time to split a monolith?
I decompose around the business, not around technical layers.
Then I sanity-check each candidate:
Anti-patterns: one service per table (entity services), splitting by layer, and services so fine-grained that every request fans out into chatty calls.
Likely follow-up: What is an aggregate in DDD, and why does it matter for service boundaries?
Each service owns its data, and other services reach it only through that service's API or events, never by querying its tables directly. Ownership can be a private set of tables, a private schema, or a separate database server; what matters is that nobody else touches it.
Why:
A shared database quietly couples services: a renamed column breaks three teams, and one service's heavy query slows everyone down.
The costs are real:
Likely follow-up: How would you build a report that needs data from five services?
An API gateway is the single entry point that external clients call; it routes each request to the right internal service, so clients don't need to know how many services exist or where they run.
It usually handles cross-cutting concerns:
The risks: it sits on every request path, so it must be highly available and fast, and it shouldn't grow into a shared monolith full of business logic that every team has to change. Keep it to routing and cross-cutting concerns; business rules belong in the services.
Likely follow-up: How does an API gateway differ from a service mesh? · What is the Backend for Frontend pattern?
Synchronous (REST or gRPC request/response): the caller waits for the answer.
Asynchronous (messages or events through a broker such as Kafka or RabbitMQ): the sender publishes and moves on.
Rule of thumb: synchronous for queries where the caller needs the data now; asynchronous for state changes that can finish in the background and for telling other services that something happened.
Instances come and go and change addresses as they scale or restart, so callers need a way to find a healthy one. Instances are recorded in a service registry, either by registering themselves or by the platform doing it for them, and they're removed when health checks fail.
Service gives a stable DNS name and virtual IP and the platform routes to ready pods, so most teams on Kubernetes don't run a separate registry.The trade-off is client complexity versus an extra hop through a component that must be highly available.
# Callers use http://inventory-service instead of pod IPs
apiVersion: v1
kind: Service
metadata:
name: inventory-service
spec:
selector:
app: inventory # routes to ready pods with this label
ports:
- port: 80
targetPort: 8080A circuit breaker wraps calls to a remote dependency and stops calling it when it's clearly failing. The caller fails fast instead of tying up threads waiting on timeouts, and the struggling service gets room to recover.
It has three states:
CallNotPermittedException.Good practice: require a minimum number of calls before evaluating the rate, so two failures at startup don't trip it; use one breaker per dependency; and emit metrics and alerts on state changes. A circuit breaker complements timeouts and retries rather than replacing them.
resilience4j:
circuitbreaker:
instances:
paymentService:
slidingWindowType: COUNT_BASED
slidingWindowSize: 20
minimumNumberOfCalls: 10
failureRateThreshold: 50 # percent
slowCallDurationThreshold: 2s
slowCallRateThreshold: 50
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 3Likely follow-up: Should the retry wrap the circuit breaker, or the other way around?
A saga keeps data consistent across services without a distributed transaction. It's a sequence of local transactions: each service updates its own database and triggers the next step with a message or event. If a step fails, the saga runs compensating transactions that semantically undo the steps already done, such as releasing reserved stock or refunding a payment.
Two ways to coordinate one:
OrderCreated, inventory reacts and emits StockReserved, and so on. It's loosely coupled and fine for a few steps, but the flow is implicit and spread across services, so it gets hard to follow, change and debug as it grows.I use choreography for short, simple flows and orchestration for longer ones with complex compensation.
Likely follow-up: How do you handle a compensating transaction that itself fails?
Inside one service I use a normal local ACID transaction. Across services there's no shared database, so the choice is a distributed transaction (2PC) or a saga.
In two-phase commit, a coordinator asks every participant to prepare (do the work, hold its locks, and vote yes or no), then tells them all to commit or abort. It gives atomicity, but:
So in microservices I use a saga, local transactions plus compensations, with a transactional outbox to publish events reliably, and I design for eventual consistency.
Likely follow-up: Is 2PC ever a reasonable choice?
Eventual consistency means that if no new updates are made, every copy of the data converges on the same value, but for a while different services or replicas can give different answers. In microservices it's the normal result of database per service plus asynchronous events: the order service knows an order is paid slightly before the shipping service's copy does.
How I design for it:
PENDING, APPROVED and REJECTED instead of pretending an operation finished instantly.202 Accepted with a status resource, show "processing", and update by polling or push when it's done.Where the business truly needs strong consistency, such as never overselling the last item, I keep that invariant inside a single service.
They're built on different models.
Kafka is a distributed, partitioned, append-only log. Producers write records to a topic's partitions, records are retained for a configured time or size whether or not anyone has read them, and each consumer group tracks its own offset.
RabbitMQ is a message broker built around AMQP. Producers publish to exchanges (direct, topic, fanout, headers), which route messages to queues through bindings, and messages are normally removed once a consumer acknowledges them.
Rule of thumb: Kafka for high-volume event streams that many consumers read and replay; RabbitMQ for task queues and rich routing.
An operation is idempotent if doing it several times has the same effect as doing it once. That matters because clients retry on timeouts, and a timeout doesn't tell you whether the first attempt succeeded. GET, PUT and DELETE are idempotent by HTTP semantics; POST is not, so a retried "create payment" could charge twice.
With an idempotency key, the client generates a unique value, such as a UUID, for each logical operation, sends it in a header (commonly Idempotency-Key) and reuses it on every retry. The server:
Keys are scoped per client, and the service passes its own key on to downstream providers so the whole chain is safe to retry.
@PostMapping("/payments")
ResponseEntity<PaymentResult> pay(@RequestHeader("Idempotency-Key") String key, @RequestBody PaymentRequest req) {
// INSERT under a unique constraint; returns the existing row if the key was seen
Optional<IdempotencyRecord> seen = idempotency.tryReserve(key, req.fingerprint());
if (seen.isPresent()) {
IdempotencyRecord r = seen.get();
if (!r.fingerprint().equals(req.fingerprint())) return ResponseEntity.unprocessableEntity().build();
if (r.inProgress()) return ResponseEntity.status(HttpStatus.CONFLICT).build();
return ResponseEntity.status(HttpStatus.CREATED).body(r.result()); // replay
}
PaymentResult result = payments.charge(req, key); // pass the key downstream too
idempotency.complete(key, result);
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}Likely follow-up: What happens if the service crashes after charging but before saving the result?
These describe what a messaging system guarantees when something fails.
In practice I design for at-least-once delivery plus idempotent consumers. That gives effectively-once results: duplicates still arrive, but processing one a second time changes nothing.
The dual-write problem appears when a service must update its database and publish a message, such as saving an order and emitting OrderPlaced. The two can't share one transaction: if the commit succeeds and the publish fails, other services never hear about the order; publish first and the commit may fail, announcing an order that doesn't exist.
With a transactional outbox, the service inserts the event into an outbox table in the same local transaction as the business change, so both commit or neither does. A separate message relay then moves outbox rows to the broker, in one of two ways:
The relay can crash after publishing but before recording that, so delivery is at-least-once; each event carries a unique id so consumers can deduplicate. Publishing each aggregate's events in commit order, keyed by aggregate id, preserves per-entity ordering.
@Transactional
public Order placeOrder(PlaceOrderCommand cmd) {
Order order = orderRepository.save(Order.pending(cmd));
// Same transaction: the event is committed if and only if the order is
outboxRepository.save(new OutboxEvent(
UUID.randomUUID(), // event id, used by consumers to deduplicate
"Order", order.getId(), // aggregate type and id (becomes the message key)
"OrderPlaced",
json.write(new OrderPlaced(order.getId(), order.getTotal()))));
return order;
}
// A relay (poller or CDC) publishes the outbox rows to the broker afterwards.Likely follow-up: What are the trade-offs between a polling relay and a CDC relay? · How would you clean up the outbox table?
Observability is being able to understand what's happening inside the system, including failures nobody predicted, from the data it emits. With dozens of services you can't log into one box and read one log file.
The three pillars:
They're far more useful when correlated: every log line carries the trace ID, and a spike on a dashboard links to example traces. OpenTelemetry is the vendor-neutral standard for producing all three. On top of that, I alert on symptoms users feel, such as error rate and latency against SLOs, rather than on every CPU blip.
A trace is one request's journey, made of spans: timed operations such as an HTTP call or a database query, each with the trace ID, its own span ID, its parent's span ID, attributes and a status. When a service calls another, it propagates the context in headers; OpenTelemetry's default is the W3C Trace Context traceparent header, carrying the trace ID, parent span ID and a sampling flag. The callee continues the same trace, and a backend such as Jaeger or Grafana Tempo assembles the spans into a timeline.
OpenTelemetry is the vendor-neutral standard for this: APIs and SDKs for traces, metrics and logs; auto-instrumentation (for Java, an agent that instruments common frameworks and clients without code changes); the OTLP protocol; and the Collector, which receives, processes and exports telemetry to the backend you choose.
Two practical points: for asynchronous flows, put the context in message headers too; and sample, either up front (head-based) or in the Collector after seeing whole traces (tail-based, which can keep every error and slow request).
Span span = tracer.spanBuilder("charge-card").startSpan();
try (Scope scope = span.makeCurrent()) {
span.setAttribute("order.id", orderId);
paymentGateway.charge(orderId, amount); // instrumented clients propagate the context
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR);
throw e;
} finally {
span.end();
}A correlation ID is a unique identifier assigned to a request at the edge, usually by the API gateway, and passed along with every downstream call and message. Each service writes it into every log line, so searching one ID in the central log store shows the whole story. Today the trace ID from W3C Trace Context usually plays this role, which also links logs to traces; some systems still add a custom header such as X-Correlation-ID.
Structured logging means emitting logs as key-value data, typically JSON, instead of free text, so fields like service, level, traceId and orderId can be filtered and aggregated reliably.
Practical points:
{
"timestamp": "2026-03-14T10:22:31.482Z",
"level": "ERROR",
"service": "payment-service",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"spanId": "00f067aa0ba902b7",
"orderId": "ord_8421",
"message": "Card authorization timed out",
"durationMs": 2003
}Failures are normal in a distributed system, so I combine patterns that each cover a different gap:
Asynchronous messaging helps too, by removing the runtime dependency altogether. In Java, Resilience4j provides these as composable decorators. The goal is failure isolation: a problem in recommendations should cost you recommendations, not checkout.
Likely follow-up: In what order would you stack a retry, a circuit breaker and a timeout?
Retries recover from transient failures such as a dropped connection, a 503 during a deploy, or a brief timeout, but naive retries make outages worse.
The rules I follow:
503, and 429 while honoring Retry-After. Never retry a 400 or a validation error.// Exponential backoff with "full jitter": a random wait in [0, min(cap, base * 2^attempt)]
static Duration backoff(int attempt) {
long baseMs = 100, capMs = 5_000;
long ceiling = Math.min(capMs, baseMs * (1L << Math.min(attempt, 20))); // 100, 200, 400...
return Duration.ofMillis(ThreadLocalRandom.current().nextLong(ceiling + 1));
}Without a timeout, a call to a slow or hung dependency blocks a thread or connection indefinitely. Under load those blocked threads pile up until the caller itself stops responding, and the failure cascades upstream. A slow dependency is often more dangerous than a dead one, because a dead one at least fails fast. Many HTTP and database clients default to no timeout or a very long one, so set them explicitly.
How I set them:
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(500)) // fail fast if we can't connect
.build();
HttpRequest request = HttpRequest.newBuilder(URI.create("http://inventory-service/api/stock/42"))
.timeout(Duration.ofSeconds(2)) // deadline for the response
.GET()
.build();
// Throws HttpTimeoutException if no response arrives within 2 seconds
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());A modular monolith is a single deployable application split internally into well-defined modules aligned with business capabilities, such as orders, billing and catalog. Each module exposes a public API and hides its internals, owns its own tables (ideally its own schema), and other modules can't reach into its data or internal classes.
Why choose it:
The catch is discipline. Boundaries must be enforced or the codebase erodes into a big ball of mud. In Java, ArchUnit tests, the Java module system or Spring Modulith can enforce the rules in the build.
A BFF is a backend built for one specific client experience, such as the web app, the mobile app or a partner API, instead of one gateway serving everyone. Each BFF shapes the API for its client: it aggregates calls to several downstream services, trims payloads to what each screen needs, and adapts to the client's constraints, such as smaller responses for mobile.
Compared with a single general-purpose gateway:
The trade-offs are some duplicated logic across BFFs and more services to run. I keep BFFs thin, doing aggregation and presentation shaping only, with business rules staying in the domain services. Many setups use both: an edge gateway for cross-cutting concerns such as TLS, authentication and rate limiting, with BFFs behind it.
REST usually means resource-oriented HTTP APIs with JSON.
curl.gRPC is an RPC framework over HTTP/2 that uses Protocol Buffers.
.proto files generate typed clients and servers in many languages.My default: REST for public and browser-facing APIs, gRPC for high-volume internal calls where performance and strict contracts matter.
CQRS, Command Query Responsibility Segregation, separates the model that handles writes (commands) from the model that serves reads (queries). The write side enforces business rules on a normalized model; the read side keeps one or more denormalized views shaped for specific queries, often in a different store such as a search index or a document database. In microservices the read side is usually updated from events, so it's eventually consistent.
Where it helps:
Where it doesn't: simple CRUD. It adds a second model, synchronization code, replication lag users can notice, and more to operate, so I apply it to the parts of a system that need it rather than as a default architecture.
With event sourcing, a service stores the sequence of events that happened to an entity instead of its current state. An account isn't a row with a balance; it's AccountOpened, MoneyDeposited, MoneyWithdrawn and so on, and current state is rebuilt by replaying them in order. The store is append-only: to change state you append an event, checking the expected version for optimistic concurrency.
Benefits:
Pitfalls:
I'd use it where history is intrinsically valuable, such as ledgers or audit-heavy domains, not as a default.
[
{ "streamId": "account-42", "version": 1, "type": "AccountOpened", "data": { "owner": "cus_19" } },
{ "streamId": "account-42", "version": 2, "type": "MoneyDeposited", "data": { "amount": 100 } },
{ "streamId": "account-42", "version": 3, "type": "MoneyWithdrawn", "data": { "amount": 30 } }
]I'd avoid a big-bang rewrite, which delays value for months and often fails. The strangler fig pattern, named by Martin Fowler after a vine that gradually grows around a tree and replaces it, migrates incrementally:
Each step is small and reversible: if the new service misbehaves, route traffic back. Adding observability and tests around the monolith first makes the whole process much safer.
They answer different questions, and Kubernetes reacts to each one differently.
Spring Boot Actuator exposes /actuator/health/liveness and /actuator/health/readiness for exactly this.
startupProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 5
failureThreshold: 30 # allow up to ~150 s to start
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
failureThreshold: 3 # restart after ~30 s of failures
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
failureThreshold: 2 # stop routing traffic quicklyFollowing the Twelve-Factor App, anything that varies between environments (URLs, credentials, feature flags, pool sizes) lives outside the code, and the same build artifact is promoted from test to production with different config.
Common approaches:
Good practices:
@RefreshScope): handy for feature flags, risky for things like pool sizes.# order-service: pull shared, per-environment config from Spring Cloud Config
spring:
application:
name: order-service # the config server looks up files by this name
config:
import: "optional:configserver:http://config-server:8888"The Twelve-Factor App is a methodology for building software-as-a-service apps that are portable, easy to deploy and scale cleanly on cloud platforms. The factors are: codebase, dependencies, config, backing services, build/release/run, processes, port binding, concurrency, disposability, dev/prod parity, logs and admin processes.
The ones I lean on most for microservices:
SIGTERM, so the platform can move instances freely.Containers package a service with its runtime and dependencies into an immutable image. The same image runs on a laptop, in CI and in production, which removes "works on my machine" problems and lets each service choose its own stack. They're light enough to run many per host.
Running dozens of services by hand doesn't scale, and Kubernetes automates it:
Service objects give stable DNS names.It's declarative: you describe the desired state and controllers keep converging on it. The cost is real complexity, so small teams often choose a managed Kubernetes service or a simpler platform.
Whatever the strategy, versions coexist, so database changes must be backward compatible: expand and contract (add the new column, deploy, backfill, remove the old one later). Feature flags also decouple deploying code from releasing a feature.
The first goal is to avoid needing a new version at all, by making backward-compatible changes:
When a breaking change is unavoidable, version explicitly:
/v2/orders) is simple and visible, and the most common choice.Then run the old and new versions side by side, announce a deprecation timeline, track who still calls the old one, and remove it once its traffic reaches zero.
The same rules apply to events: evolve schemas compatibly, ideally checked automatically by a schema registry. With Protobuf, never reuse a field number; mark removed ones reserved. Contract tests catch accidental breaking changes before deployment.
The problem: a provider changes its API, its own tests pass, and a consumer breaks in production. End-to-end tests could catch it, but they're slow, flaky and need every service deployed together.
With consumer-driven contracts, each consumer records exactly what it needs from a provider: the requests it sends and the parts of the responses it relies on.
So the provider learns about a breaking change in its own CI, before deploying, and knows which consumers are affected. It also reveals fields nobody uses, which makes them safe to remove.
Pact is the best-known tool; its broker's can-i-deploy check answers whether a version is safe to release. Spring Cloud Contract is a common JVM alternative. Contract tests check the shape of interactions, not business behavior, so they complement functional tests.
I test each service thoroughly in isolation and keep cross-service end-to-end tests to a minimum.
Because staging never matches production exactly, I add testing in production safeguards: canary releases with automated rollback, feature flags, synthetic monitoring of key flows and strong observability. Resilience can be tested too, by injecting failures such as added latency or a killed instance and checking that timeouts and fallbacks behave.
Rate limiting caps how many requests a client can make in a period. It protects services from overload, abusive clients and runaway retry loops, and enforces fair use or paid quotas. Rejected requests get 429 Too Many Requests, ideally with Retry-After.
Common algorithms:
Per-instance limits are simple but inexact, since the effective limit grows with the replica count. For a global limit, keep counters in a shared store such as Redis, updated atomically. The API gateway is the natural place for per-client limits. Throttling usually means the softer version: slowing or queuing requests instead of rejecting them.
class TokenBucket {
private final long capacity; private final double refillPerNano;
private double tokens; private long last = System.nanoTime();
TokenBucket(long capacity, double perSecond) {
this.capacity = capacity; this.tokens = capacity; this.refillPerNano = perSecond / 1e9;
}
synchronized boolean tryAcquire() {
long now = System.nanoTime();
tokens = Math.min(capacity, tokens + (now - last) * refillPerNano); // refill
last = now;
if (tokens < 1) return false; // caller responds with 429
tokens -= 1; return true;
}
}Caching cuts latency and load, but every cache is a copy that can go stale.
Patterns:
Where caches live: in-process (such as Caffeine) is fastest, but each instance has its own copy; distributed (such as Redis) is shared; HTTP caching with Cache-Control and ETag works at the gateway or CDN.
Keeping it correct:
Named after the watertight compartments in a ship's hull, a bulkhead isolates resources so that a failure in one part can't sink the whole service.
The classic problem: a service calls three dependencies from one shared thread pool. One dependency becomes slow, every thread ends up waiting on it, and requests that never touch that dependency start failing too.
Ways to apply it:
Resilience4j provides both a semaphore Bulkhead and a ThreadPoolBulkhead. Combined with timeouts and a circuit breaker, it keeps a slow recommendations service from starving checkout.
resilience4j:
bulkhead: # semaphore: limits concurrent calls
instances:
recommendations:
maxConcurrentCalls: 10
maxWaitDuration: 10ms # then reject instead of queuing
thread-pool-bulkhead: # a separate pool and queue per dependency
instances:
reporting:
coreThreadPoolSize: 2
maxThreadPoolSize: 4
queueCapacity: 20Brokers implement these differently:
A common setup is pub-sub between services and competing consumers among the instances of each service.
A Kafka topic is split into partitions, each an ordered, append-only log replicated across brokers. Kafka guarantees order only within a partition, not across a whole topic.
To keep per-entity order:
Watch for hot partitions from skewed keys, and avoid adding partitions to a topic that relies on key ordering, because keys get remapped.
// Same key -> same partition -> events for one order are consumed in order
producer.send(new ProducerRecord<>("order-events", order.getId(), eventJson));In an event-driven architecture, services communicate by publishing events, facts about something that already happened, such as OrderPlaced or PaymentFailed, and other services react. The publisher doesn't know or care who's listening.
How much data to include is a real design choice:
The benefits are loose coupling and easy extension; the costs are eventual consistency, flows that are hard to see end to end, and event schemas that become public contracts.
{
"eventId": "e7a1c0de-5b2f-4c1e-9d3a-2f6b8c4e1a90",
"type": "OrderPlaced",
"version": 2,
"occurredAt": "2026-03-14T10:22:31Z",
"orderId": "ord_8421",
"customerId": "cus_19",
"items": [{ "sku": "BOOK-17", "quantity": 2 }],
"total": { "amount": 4998, "currency": "EUR" }
}Duplicates will happen eventually, from producer retries, relay crashes, or a consumer that processed a message but crashed before acknowledging it. So each handler must produce the same result whether a message arrives once or five times.
Approaches:
processed_messages table with a primary key. A duplicate either finds the id and is skipped, or fails the insert and rolls back. Checking and changing in separate transactions leaves a gap.UPDATE ... WHERE status = 'PENDING', which also guards against out-of-order events.The processed-id table needs a retention policy, longer than any realistic redelivery window.
@Transactional
public void on(PaymentCompleted event) {
// message_id is the primary key: a concurrent duplicate fails and rolls back
if (processedMessages.existsById(event.eventId())) {
return; // already handled: just acknowledge
}
processedMessages.save(new ProcessedMessage(event.eventId()));
orderRepository.markPaid(event.orderId()); // same transaction as the dedupe record
}A poison message fails every time it's processed, because of bad data, a schema mismatch or a bug. Retried forever, it blocks everything behind it (in Kafka, its whole partition) or burns resources in an endless redelivery loop.
The fix is to separate transient failures from permanent ones:
Broker support differs. RabbitMQ has dead-letter exchanges: a message is dead-lettered when it's rejected without requeue, expires, or overflows a queue's length limit. Kafka has no built-in DLQ; frameworks add one, such as Spring Kafka's DeadLetterPublishingRecoverer and its non-blocking retry topics.
One trade-off: moving a message aside breaks per-key ordering, so later events for that entity may need to be parked too.
Zero trust means no request is trusted just because it comes from inside the network. The perimeter model assumed everything behind the firewall was safe, so one compromised service could reach everything. Instead, every call is authenticated, authorized and encrypted, with least-privilege access.
The building blocks:
POST /payments".# Istio: require mTLS for every workload in the namespace
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: default
namespace: shop
spec:
mtls:
mode: STRICTA service mesh moves service-to-service networking out of application code and into infrastructure. In the classic design every pod gets a sidecar proxy, commonly Envoy, that intercepts its inbound and outbound traffic, and a control plane, as in Istio or Linkerd, configures all the proxies. Some meshes also offer sidecar-less modes with shared per-node proxies.
What it gives you, consistently across languages:
The costs:
It pays off with many services, several languages or strict mTLS needs; for a handful of services, libraries may be enough.
The CAP theorem says that when a network partition occurs, a distributed data system must choose between consistency (every read sees the latest write; in CAP this means linearizability, not the C in ACID) and availability (every request to a non-failed node gets a response). Partitions can't be ruled out, so the real choice is CP or AP during a partition. It's often misquoted as "pick any two of three".
PACELC extends it: if there's a Partition (P), choose Availability (A) or Consistency (C); Else (E), in normal operation, choose between Latency (L) and Consistency (C). Even without failures, replicating every write synchronously before answering costs latency.
For microservices:
There are two main patterns, plus one rule: never reach into another service's database.
A lighter variant is data replication: keep a local copy of just the fields you need from another service's events, such as the customer's name on an order.
I use composition for simple lookups and read models for heavy or complex queries.
Change data capture reads a database's transaction log, such as the PostgreSQL write-ahead log through logical decoding or the MySQL binlog, and turns each committed insert, update and delete into an event. Debezium, usually running on Kafka Connect, is the best-known open-source tool; the snippet shows a simplified update event.
Why it's useful:
The catch: streaming raw table changes publishes your internal schema, so consumers couple to column names and a refactor breaks them. That's where the outbox fits: the service writes deliberate domain events to an outbox table in the same transaction as its change, and CDC publishes only that table. Debezium has an outbox event router for this.
Operationally, watch replication slots (an unconsumed PostgreSQL slot makes the database retain WAL until the disk fills), initial snapshots and schema changes.
{
"op": "u",
"before": { "id": 42, "status": "PENDING" },
"after": { "id": 42, "status": "PAID" },
"source": { "connector": "postgresql", "table": "orders", "lsn": 24023128 },
"ts_ms": 1773483751000
}I'd model it as an orchestrated saga owned by the Order service, since it has several steps and compensations.
PENDING in a local transaction, writing OrderCreated to the outbox. Return 202 Accepted with the order id so the client can poll or be notified.REJECTED.CONFIRMED and commit the reservation; capture the payment later, at shipping.Cross-cutting safeguards:
Likely follow-up: Which step is the pivot transaction in this saga? · How would you handle a customer cancelling after payment?
A compensating transaction is a semantic undo, not a rollback. The original change was committed and others may already have seen it, so you apply a new change that reverses its business effect: a refund, a stock release, a cancellation notice.
Good compensations are:
Sagas give you ACD without the I: other transactions can see intermediate state, risking lost updates and dirty reads. Countermeasures:
APPROVAL_PENDING that tells others the record is mid-saga.// Simplified: real orchestrators persist saga state and run steps asynchronously
List<SagaStep> done = new ArrayList<>();
try {
for (SagaStep step : List.of(reserveStock, authorizePayment, confirmOrder)) {
step.execute(order);
done.add(step);
}
} catch (BusinessFailure e) {
Collections.reverse(done); // undo in reverse order
for (SagaStep step : done) step.compensate(order); // idempotent, retried until done
order.reject(e.reason());
}A distributed monolith has the deployment topology of microservices but the coupling of a monolith: you pay for the network and for operating many services without gaining independent deployability. It's arguably worse than either.
Warning signs:
The fixes: redraw boundaries around business capabilities and merge services that always change together; give each service its own data; replace synchronous chains with events and local copies of data; keep shared libraries to technical plumbing; and evolve APIs backward-compatibly, backed by contract tests.
In a monolith a call either works or the whole process is down. Across a network, a dependency can be slow, down, or have succeeded without you knowing, so I design for each case.
Then verify it by injecting latency and failures deliberately.
For each service I start with request-level metrics, often summarized as RED:
For resources, USE: utilization, saturation and errors for CPU, memory, connection pools, thread pools and queues. The "four golden signals" combine the two: latency, traffic, errors and saturation.
Then service-specific ones: consumer lag for message consumers, circuit breaker state, retry counts, cache hit ratio, and business metrics such as orders per minute, which often catch problems technical metrics miss.
For alerting, I define SLOs, such as 99.9% of requests succeeding within 300 ms, and alert on user-facing symptoms like error-budget burn rate rather than every CPU spike. Every alert should be actionable and link to a dashboard or runbook. In Java, Micrometer exposes these metrics to Prometheus or via OpenTelemetry.
Conway's law, named after Melvin Conway, observes that organizations design systems that mirror their own communication structures.
For microservices it cuts both ways:
So service boundaries and team boundaries should be designed together. The inverse Conway maneuver means shaping teams deliberately to get the architecture you want. Practical guidelines:
Microservices are as much an organizational choice as a technical one; without autonomous teams, much of their benefit disappears.
Within Kafka, yes, for read-process-write pipelines.
transactional.id can write to several partitions and commit the consumer offsets of the input it read, atomically: either the outputs and the offset commit all become visible, or none do.isolation.level=read_committed so they never see records from aborted transactions.processing.guarantee=exactly_once_v2.The boundary: this covers only state inside Kafka. If processing also writes to a database, calls an API or sends an email, those side effects aren't part of the Kafka transaction and can repeat after a crash. For them you're back to at-least-once plus idempotency: dedupe by message id in the database transaction, use idempotency keys for external calls, or store the consumer offset in the same database transaction as the result.
spring:
kafka:
producer:
transaction-id-prefix: order-tx- # enables transactional producers
consumer:
isolation-level: read_committed # skip records from aborted transactions
enable-auto-commit: falseNo questions match that filter.
Prefer multiple choice? All 22 Microservices MCQs with answers →