View source on GitHub

Module 46 - Microservices Architecture

Overview

A microservices architecture decomposes a system into independently deployable services, each owning its own data. The benefits - independent scaling, isolated failures, small deployment units - come with a fundamental cost: there is no longer a single shared database, so distributed consistency requires careful design.

This module covers the two most important patterns for handling that cost: the transactional outbox (reliable event publishing) and the saga (multi-step distributed operation with compensating transactions).


1. Service decomposition

The core decomposition principle is bounded context: each service owns a slice of the domain and its data store. No service reads another’s database directly.

Common decomposition strategies:

Strategy Split by Example
Business capability What the business does Orders, Inventory, Payments
Subdomain (DDD) Domain expert’s mental model Catalogue, Fulfilment, Finance
Strangler fig Incrementally replace a monolith Route /orders to new service first

Rules of thumb:

  • A service should be changeable and deployable without coordinating with other teams
  • If two pieces of data must be updated in the same transaction, they belong in the same service
  • Start with fewer, larger services; split when team or deployment friction demands it

2. Inter-service communication

Synchronous: RestClient (Spring Boot 3.2+)

RestClient is the synchronous successor to RestTemplate, with a fluent API similar to WebClient but blocking.

@Component
public class InventoryClient {

    private final RestClient restClient;

    public InventoryClient(RestClient.Builder builder,
                           @Value("${inventory.service.url}") String baseUrl) {
        this.restClient = builder.baseUrl(baseUrl).build();
    }

    public boolean reserve(Long productId, int quantity) {
        try {
            restClient.post()
                    .uri("/inventory/reserve")
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(new ReservationRequest(productId, quantity))
                    .retrieve()
                    .toBodilessEntity();
            return true;
        } catch (RestClientException e) {
            return false;   // caller decides how to compensate
        }
    }
}

RestClient.Builder is auto-configured as a prototype bean - each client gets its own instance with its own baseUrl, independent of others.

Asynchronous: messaging

Synchronous calls couple the caller to the availability of the downstream service. Messaging (Kafka, RabbitMQ) decouples them: the caller publishes an event and continues without waiting. The downstream service processes the event when it is ready.

The outbox pattern (Section 4) is the safe way to publish those events.


3. Saga pattern

A saga is a sequence of local transactions, each within a single service’s database, that together implement a distributed operation. Because there is no two-phase commit across services, sagas achieve eventual consistency through compensating transactions that undo the effects of earlier steps on failure.

Choreography vs orchestration

  Choreography Orchestration
Control Each service reacts to events A central orchestrator drives the flow
Coupling Services know about events, not each other Orchestrator knows all participants
Observability Harder - flow is implicit Easier - flow is explicit in one place
Best for Simple, few steps Complex flows, clear rollback logic

Orchestration-based saga

OrderCreationSaga.execute(request)
  │
  ├── 1. orderService.createPendingOrder(request)    ← local tx: Order(PENDING) + OutboxEvent
  │
  ├── 2. inventoryClient.reserve(productId, qty)     ← HTTP call (outside any transaction)
  │        │
  │        ├── true  → orderService.confirmOrder(id) ← local tx: Order(CONFIRMED) + OutboxEvent
  │        └── false → orderService.cancelOrder(id)  ← compensating tx: Order(CANCELLED) + OutboxEvent
@Component
@RequiredArgsConstructor
public class OrderCreationSaga {

    private final OrderService orderService;
    private final InventoryClient inventoryClient;

    public Order execute(OrderRequest request) {
        Order pending = orderService.createPendingOrder(request);

        boolean reserved = inventoryClient.reserve(pending.getProductId(), pending.getQuantity());

        return reserved
                ? orderService.confirmOrder(pending.getId())
                : orderService.cancelOrder(pending.getId());
    }
}

Why the HTTP call is between two local transactions: Holding a database connection open while waiting for a network call blocks the connection pool. The PENDING state and outbox event make the intermediate state explicit and recoverable if the process crashes between the two transactions.


4. Transactional outbox pattern

Publishing an event to a message broker and writing to the database must be atomic. If the database write succeeds but the broker publish fails (or the process crashes), the event is lost forever. If the broker publish happens first and the database write fails, a spurious event is published.

The outbox pattern solves this by writing the event to an outbox_events table in the same transaction as the domain change. A separate publisher polls for unpublished events and forwards them to the broker.

Writer (saga)                         Outbox publisher (scheduled)
─────────────────────────────────     ──────────────────────────────────
BEGIN TX                              SELECT * FROM outbox_events
  INSERT INTO orders (status=PENDING)   WHERE published = false;
  INSERT INTO outbox_events (...)
COMMIT                                for each event:
                                        publish to broker
                                        UPDATE outbox_events
                                          SET published = true

Entities

@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
    @Id @GeneratedValue Long id;
    String aggregateType;   // "Order"
    Long aggregateId;       // order id
    String eventType;       // "OrderCreated", "OrderConfirmed", "OrderCancelled"
    String payload;         // JSON
    boolean published;
    LocalDateTime createdAt;
}

Atomic write (same transaction as the domain change)

@Service
@RequiredArgsConstructor
public class OrderService {

    @Transactional
    public Order createPendingOrder(OrderRequest request) {
        Order order = orderRepository.save(Order.builder()
                .productId(request.productId())
                .quantity(request.quantity())
                .status(OrderStatus.PENDING)
                .createdAt(LocalDateTime.now())
                .build());
        outboxEventRepository.save(OutboxEvent.forOrder(order, "OrderCreated"));
        return order;
    }
}

Publisher (polls and marks as sent)

@Component
@RequiredArgsConstructor
public class OutboxEventPublisher {

    @Scheduled(fixedDelay = 5000)
    @Transactional
    public void publishPendingEvents() {
        List<OutboxEvent> pending = outboxEventRepository.findByPublishedFalse();
        for (OutboxEvent event : pending) {
            // send to Kafka/RabbitMQ in production
            log.info("Publishing: type={}, aggregateId={}", event.getEventType(), event.getAggregateId());
            event.setPublished(true);
        }
    }
}

Guarantees and trade-offs:

  • At-least-once delivery - if the publisher crashes after sending but before marking published = true, the event is re-sent on the next poll. Consumers must be idempotent.
  • No event loss - an event only disappears after it has been successfully forwarded.
  • Simple to implement with only a relational database - no distributed transaction coordinator.

5. Testing

Saga - pure unit test

The saga’s logic can be tested with Mockito alone: mock OrderService and InventoryClient, verify the correct service methods are called in the correct order.

@ExtendWith(MockitoExtension.class)
class OrderCreationSagaTest {

    @Mock OrderService orderService;
    @Mock InventoryClient inventoryClient;
    @InjectMocks OrderCreationSaga saga;

    @Test
    void confirms_order_when_inventory_is_available() {
        when(orderService.createPendingOrder(any())).thenReturn(pendingOrder);
        when(inventoryClient.reserve(1L, 2)).thenReturn(true);
        when(orderService.confirmOrder(1L)).thenReturn(confirmedOrder);

        Order result = saga.execute(new OrderRequest(1L, 2));

        assertThat(result.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
        verify(orderService, never()).cancelOrder(any());
    }

    @Test
    void cancels_order_when_inventory_is_unavailable() {
        when(orderService.createPendingOrder(any())).thenReturn(pendingOrder);
        when(inventoryClient.reserve(1L, 2)).thenReturn(false);
        when(orderService.cancelOrder(1L)).thenReturn(cancelledOrder);

        Order result = saga.execute(new OrderRequest(1L, 2));

        assertThat(result.getStatus()).isEqualTo(OrderStatus.CANCELLED);
        verify(orderService, never()).confirmOrder(any());
    }

    @Test
    void always_creates_pending_order_before_calling_inventory() {
        // ...
        var inOrder = inOrder(orderService, inventoryClient);
        inOrder.verify(orderService).createPendingOrder(any());
        inOrder.verify(inventoryClient).reserve(1L, 2);
        inOrder.verify(orderService).confirmOrder(1L);
    }
}

Outbox pattern - @DataJpaTest

Verify the two writes happen in the same transaction and the query for unpublished events works correctly.

@DataJpaTest
class OutboxPatternTest {

    @Test
    void order_and_outbox_event_are_persisted_atomically() {
        Order order = orderRepository.save(Order.builder()...build());
        outboxEventRepository.save(OutboxEvent.forOrder(order, "OrderCreated"));

        em.flush(); em.clear();

        assertThat(orderRepository.count()).isEqualTo(1);
        assertThat(outboxEventRepository.findByPublishedFalse()).hasSize(1);
    }
}

Key takeaways

  • Decompose by bounded context - each service owns its data; no shared databases
  • Synchronous communication uses RestClient (Spring Boot 3.2+); asynchronous uses messaging - prefer async to avoid availability coupling between services
  • Saga replaces cross-service distributed transactions with a sequence of local transactions and compensating transactions on failure
  • Orchestration-based sagas keep the flow in one place (easier to reason about); choreography-based sagas have no central coordinator (looser coupling, harder to trace)
  • The outbox pattern makes event publishing atomic with database writes - the event is written to an outbox_events table in the same transaction, then a poller forwards it to the broker. This gives at-least-once delivery without a distributed transaction coordinator
  • Consumers of outbox-published events must be idempotent: the same event may arrive more than once if the publisher crashes between sending and marking the event as published