Skip to content

Commit c92197c

Browse files
committed
feat: add Event-Carried State Transfer pattern (#2434)
1 parent 41625d8 commit c92197c

20 files changed

Lines changed: 1716 additions & 0 deletions
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
---
2+
title: "Event-Carried State Transfer Pattern in Java: Keeping Services Autonomous with Stateful Events"
3+
shortTitle: Event-Carried State Transfer
4+
description: "Learn the Event-Carried State Transfer (ECST) pattern in Java: publish events that carry the full state of a changed entity so consumers keep their own replica, never call the producer back, and keep working when it is down. Includes a runnable example, diagrams, and trade-offs."
5+
category: Messaging
6+
language: en
7+
tag:
8+
- Data transfer
9+
- Decoupling
10+
- Event-driven
11+
- Messaging
12+
- Microservices
13+
---
14+
15+
## Also known as
16+
17+
* ECST
18+
* Stateful events
19+
* Fat events
20+
21+
## Intent of Event-Carried State Transfer Design Pattern
22+
23+
Propagate state changes as events that carry the complete new state of the changed entity, so that consuming services can maintain their own local copy of the data and act on it without querying the producing service.
24+
25+
## Detailed Explanation of Event-Carried State Transfer Pattern with Real-World Examples
26+
27+
Real-world example
28+
29+
> A retailer's customer service is the system of record for names, shipping addresses and credit limits. The order service needs that data for every order. Instead of calling the customer service on each order, the customer service publishes a `CustomerUpdated` event every time a customer changes, and the event contains the customer's full record. The order service keeps its own copy of the customers it has seen and ships orders from that copy. When the customer service goes down for maintenance, orders keep flowing.
30+
31+
In plain words
32+
33+
> Do not just tell other services that something changed, send them the whole new state so they never have to ask.
34+
35+
Martin Fowler says
36+
37+
> Event-Carried State Transfer ... shows up when you want to update clients of a system in such a way that they don't need to contact the source system in order to do further work. ... The consumer can then process the data in its own way, and doesn't need to contact the source system.
38+
39+
Sequence diagram
40+
41+
```mermaid
42+
sequenceDiagram
43+
participant CS as Customer service (producer)
44+
participant Bus as Event bus
45+
participant OS as Order service (consumer)
46+
participant R as Customer replica
47+
48+
CS->>CS: change address, version 1 -> 2
49+
CS->>Bus: CustomerUpdatedEvent(full state, version 2)
50+
Bus->>OS: deliver event
51+
OS->>R: apply (version 2 > 1, upsert)
52+
Note over CS: customer service goes offline
53+
OS->>R: find customer C-1
54+
R-->>OS: state version 2 (address, credit limit)
55+
OS-->>OS: order accepted, ships to the replicated address
56+
Bus->>OS: stale CustomerUpdatedEvent(version 1)
57+
OS->>R: apply (version 1 <= 2, ignored)
58+
```
59+
60+
## Programmatic Example of Event-Carried State Transfer Pattern in Java
61+
62+
The example has a producer, a channel and a consumer. The producer is the customer service, the channel is a tiny in-memory event bus and the consumer is the order service with its local customer replica.
63+
64+
1. **The state and the event that carries it**
65+
66+
`CustomerState` is the complete record of a customer, including a version that grows with every change. `CustomerUpdatedEvent` embeds the whole state, which is what distinguishes this pattern from a plain event notification.
67+
68+
```java
69+
public record CustomerState(
70+
String customerId, String name, String shippingAddress, BigDecimal creditLimit, long version) {
71+
72+
public CustomerState withShippingAddress(String newAddress) {
73+
return new CustomerState(customerId, name, newAddress, creditLimit, version + 1);
74+
}
75+
76+
public CustomerState withCreditLimit(BigDecimal newLimit) {
77+
return new CustomerState(customerId, name, shippingAddress, newLimit, version + 1);
78+
}
79+
}
80+
81+
public record CustomerUpdatedEvent(long eventId, Instant occurredAt, CustomerState state) {}
82+
```
83+
84+
2. **The channel**
85+
86+
`EventBus` is a synchronous publish/subscribe channel keyed by event class. In production this role is played by a message broker.
87+
88+
```java
89+
public <E> void subscribe(Class<E> eventType, EventListener<? super E> listener) {
90+
var subscribers = listeners.computeIfAbsent(eventType, key -> new ArrayList<>());
91+
subscribers.add(listener);
92+
LOGGER.info("Subscriber {} registered for {}", subscribers.size(), eventType.getSimpleName());
93+
}
94+
95+
public void publish(Object event) {
96+
var subscribers = listeners.getOrDefault(event.getClass(), List.of());
97+
if (subscribers.isEmpty()) {
98+
LOGGER.warn("No subscribers for {}", event.getClass().getSimpleName());
99+
return;
100+
}
101+
for (var listener : subscribers) {
102+
deliver(listener, event);
103+
}
104+
}
105+
```
106+
107+
3. **The producer**
108+
109+
`CustomerService` owns the authoritative data. Every change is stored and then announced with the full new state. `findCustomer` is the direct query a consumer would have to make without the pattern, and it stops working once the service is shut down.
110+
111+
```java
112+
public CustomerState changeShippingAddress(String customerId, String newAddress) {
113+
LOGGER.info("Customer {} moves to {}", customerId, newAddress);
114+
return store(existing(customerId).withShippingAddress(newAddress));
115+
}
116+
117+
private CustomerState store(CustomerState state) {
118+
customers.put(state.customerId(), state);
119+
var event = new CustomerUpdatedEvent(eventSequence.incrementAndGet(), Instant.now(), state);
120+
bus.publish(event);
121+
return state;
122+
}
123+
124+
public Optional<CustomerState> findCustomer(String customerId) {
125+
if (!online) {
126+
throw new IllegalStateException("customer service is offline");
127+
}
128+
return Optional.ofNullable(customers.get(customerId));
129+
}
130+
```
131+
132+
4. **The consumer's replica**
133+
134+
`CustomerReplica` is the local copy fed only by events. Applying an event is an upsert guarded by the version, so events that arrive late or twice are ignored.
135+
136+
```java
137+
public boolean apply(CustomerUpdatedEvent event) {
138+
var incoming = event.state();
139+
var current = customers.get(incoming.customerId());
140+
if (current != null && current.version() >= incoming.version()) {
141+
LOGGER.info("Ignoring event {} for {}: version {} is not newer than replica version {}", ...);
142+
return false;
143+
}
144+
customers.put(incoming.customerId(), incoming);
145+
return true;
146+
}
147+
```
148+
149+
5. **The consumer**
150+
151+
`OrderService` subscribes its replica to the bus and afterwards reads only from the replica. It holds no reference to the customer service.
152+
153+
```java
154+
public OrderService(EventBus bus) {
155+
bus.subscribe(CustomerUpdatedEvent.class, replica::apply);
156+
}
157+
158+
public Order placeOrder(String customerId, BigDecimal amount) {
159+
var customer =
160+
replica
161+
.find(customerId)
162+
.orElseThrow(
163+
() -> new OrderRejectedException("Unknown customer " + customerId + " in replica"));
164+
if (amount.compareTo(customer.creditLimit()) > 0) {
165+
throw new OrderRejectedException(
166+
"Amount " + amount + " exceeds credit limit " + customer.creditLimit() + " of " + customerId);
167+
}
168+
return new Order(
169+
"ORD-" + orderSequence.incrementAndGet(), customerId, customer.shippingAddress(), amount);
170+
}
171+
```
172+
173+
6. **The demo**
174+
175+
`App` registers a customer and changes the address, takes the customer service offline and places an order from the replica, publishes a stale event that the replica ignores, and finally shows the replica enforcing the credit limit.
176+
177+
```java
178+
var bus = new EventBus();
179+
var customerService = new CustomerService(bus);
180+
var orderService = new OrderService(bus);
181+
182+
customerService.register("C-1", "Alice", "1 Harbour Street, Lisbon", new BigDecimal("500.00"));
183+
customerService.changeShippingAddress("C-1", "42 Ocean Avenue, Porto");
184+
185+
customerService.shutdown();
186+
lookUpDirectly(customerService, "C-1"); // fails, the producer is down
187+
var order = orderService.placeOrder("C-1", new BigDecimal("120.00")); // succeeds from the replica
188+
189+
bus.publish(new CustomerUpdatedEvent(99, Instant.now(), staleVersionOne)); // ignored
190+
tryToOrder(orderService, "C-1", new BigDecimal("900.00")); // rejected, above the replicated limit
191+
```
192+
193+
Program output:
194+
195+
```
196+
INFO EventBus -- Subscriber 1 registered for CustomerUpdatedEvent
197+
INFO App -- --- Step 1: every customer change is published with the full customer state ---
198+
INFO CustomerService -- Registering customer C-1 (Alice)
199+
INFO CustomerService -- Publishing event 1 with the full state of C-1 (version 1)
200+
INFO EventBus -- Publishing CustomerUpdatedEvent to 1 subscriber(s)
201+
INFO CustomerReplica -- Replica updated from event 1: C-1 is now at version 1 with address '1 Harbour Street, Lisbon' and limit 500.00
202+
INFO App -- Order service replica: C-1 version 1 at '1 Harbour Street, Lisbon' with limit 500.00
203+
INFO CustomerService -- Customer C-1 moves to 42 Ocean Avenue, Porto
204+
INFO CustomerService -- Publishing event 2 with the full state of C-1 (version 2)
205+
INFO EventBus -- Publishing CustomerUpdatedEvent to 1 subscriber(s)
206+
INFO CustomerReplica -- Replica updated from event 2: C-1 is now at version 2 with address '42 Ocean Avenue, Porto' and limit 500.00
207+
INFO App -- Order service replica: C-1 version 2 at '42 Ocean Avenue, Porto' with limit 500.00
208+
INFO App -- --- Step 2: the customer service goes offline, orders still flow from the replica ---
209+
WARN CustomerService -- Customer service is going offline
210+
WARN App -- Direct lookup of C-1 failed: customer service is offline
211+
INFO OrderService -- Accepted ORD-1 for C-1 (120.00) shipping to '42 Ocean Avenue, Porto' using replica version 2
212+
INFO App -- ORD-1 ships to '42 Ocean Avenue, Porto' without asking the customer service
213+
INFO App -- --- Step 3: a stale event arrives late and the replica ignores it ---
214+
INFO EventBus -- Publishing CustomerUpdatedEvent to 1 subscriber(s)
215+
INFO CustomerReplica -- Ignoring event 99 for C-1: version 1 is not newer than replica version 2
216+
INFO App -- Order service replica: C-1 version 2 at '42 Ocean Avenue, Porto' with limit 500.00
217+
INFO App -- --- Step 4: the replica is enough to enforce business rules ---
218+
WARN App -- Order rejected: Amount 900.00 exceeds credit limit 500.00 of C-1
219+
WARN App -- Order rejected: Unknown customer C-2 in replica
220+
```
221+
222+
## Class diagram
223+
224+
See [event-carried-state-transfer.urm.puml](./etc/event-carried-state-transfer.urm.puml) for the PlantUML class diagram.
225+
226+
## When to Use the Event-Carried State Transfer Pattern in Java
227+
228+
* Consumers need data owned by another service on every request and a synchronous call would add latency, load, or a hard availability dependency.
229+
* The producer must stay available and responsive regardless of how many consumers depend on its data.
230+
* Consumers can tolerate eventual consistency, reading data that is a few events behind the producer.
231+
* Several services need their own view of the same data, possibly stored in different shapes.
232+
233+
## Real-World Applications of Event-Carried State Transfer Pattern in Java
234+
235+
* Kafka topics that carry full entity snapshots, consumed by services that build local materialized views.
236+
* Change data capture pipelines such as Debezium, which stream the complete row state after each database change.
237+
* Product catalogue or customer master data replicated into search, pricing, and fulfilment services in e-commerce platforms.
238+
239+
## Benefits and Trade-offs of Event-Carried State Transfer Pattern
240+
241+
Benefits:
242+
243+
* Consumers are autonomous: they answer from local data and keep working while the producer is down.
244+
* The producer is not queried by consumers, so its load does not grow with the number of consumers.
245+
* Every event is self-contained, which makes consumers simple to write and test.
246+
247+
Trade-offs:
248+
249+
* Eventual consistency: a consumer may act on data that is one or more events behind.
250+
* Data is duplicated across services and every consumer has to store what it needs.
251+
* Events are larger than plain notifications, and their schema has to be versioned and evolved carefully.
252+
* Consumers must handle out-of-order and duplicated deliveries, for example with the version check shown here.
253+
254+
## Related Java Design Patterns
255+
256+
* [Event-Driven Architecture](../event-driven-architecture): the overall style in which services react to events; ECST is one of the ways events are used in it.
257+
* [Publish-Subscribe](../publish-subscribe): the delivery mechanism the state-carrying events ride on.
258+
* [Event Sourcing](../event-sourcing): stores events as the system of record and rebuilds state by replaying them.
259+
* [Microservices Messaging](../microservices-messaging): asynchronous communication between services, which ECST relies on.
260+
* [Command Query Responsibility Segregation](../command-query-responsibility-segregation): read models fed by ECST events are a common way to build the query side.
261+
262+
How this pattern differs from its neighbours: an event notification carries only an identifier and forces the consumer to call the producer back for details. Event-Carried State Transfer carries the full state, so the consumer keeps a local replica and never calls back. Event Sourcing keeps the events themselves as the source of truth; ECST only uses events to feed replicas while the producer remains the source of truth. Publish-Subscribe is the channel; ECST is about what the messages on that channel contain.
263+
264+
## References and Credits
265+
266+
* [What do you mean by "Event-Driven"? (Martin Fowler)](https://martinfowler.com/articles/201701-event-driven.html)
267+
* [Stateful Event Pattern (Graham Brooks)](https://www.grahambrooks.com/event-driven-architecture/patterns/stateful-event-pattern/)
268+
* [The Event-Carried State Transfer Pattern (itnext)](https://itnext.io/the-event-carried-state-transfer-pattern-aae49715bb7f)
269+
* [Microservices Patterns: With examples in Java](https://amzn.to/3xaZwk0)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
@startuml
2+
package com.iluwatar.eventcarriedstatetransfer {
3+
class CustomerState {
4+
- customerId : String
5+
- name : String
6+
- shippingAddress : String
7+
- creditLimit : BigDecimal
8+
- version : long
9+
+ CustomerState(customerId : String, name : String, shippingAddress : String, creditLimit : BigDecimal, version : long)
10+
+ customerId() : String
11+
+ name() : String
12+
+ shippingAddress() : String
13+
+ creditLimit() : BigDecimal
14+
+ version() : long
15+
+ withShippingAddress(newAddress : String) : CustomerState
16+
+ withCreditLimit(newLimit : BigDecimal) : CustomerState
17+
}
18+
class CustomerUpdatedEvent {
19+
- eventId : long
20+
- occurredAt : Instant
21+
- state : CustomerState
22+
+ CustomerUpdatedEvent(eventId : long, occurredAt : Instant, state : CustomerState)
23+
+ eventId() : long
24+
+ occurredAt() : Instant
25+
+ state() : CustomerState
26+
}
27+
interface EventListener<E> {
28+
+ onEvent(event : E) : void {abstract}
29+
}
30+
class EventBus {
31+
- listeners : Map<Class<?>, List<EventListener<?>>>
32+
+ EventBus()
33+
+ subscribe(eventType : Class<E>, listener : EventListener<? super E>) : void
34+
+ publish(event : Object) : void
35+
}
36+
class CustomerService {
37+
- customers : Map<String, CustomerState>
38+
- bus : EventBus
39+
- eventSequence : AtomicLong
40+
- online : boolean
41+
+ CustomerService(bus : EventBus)
42+
+ register(customerId : String, name : String, shippingAddress : String, creditLimit : BigDecimal) : CustomerState
43+
+ changeShippingAddress(customerId : String, newAddress : String) : CustomerState
44+
+ changeCreditLimit(customerId : String, newLimit : BigDecimal) : CustomerState
45+
+ findCustomer(customerId : String) : Optional<CustomerState>
46+
+ shutdown() : void
47+
+ isOnline() : boolean
48+
}
49+
class CustomerReplica {
50+
- customers : Map<String, CustomerState>
51+
+ CustomerReplica()
52+
+ apply(event : CustomerUpdatedEvent) : boolean
53+
+ find(customerId : String) : Optional<CustomerState>
54+
+ size() : int
55+
}
56+
class OrderService {
57+
- replica : CustomerReplica
58+
- orderSequence : AtomicLong
59+
+ OrderService(bus : EventBus)
60+
+ placeOrder(customerId : String, amount : BigDecimal) : Order
61+
+ replica() : CustomerReplica
62+
}
63+
class Order {
64+
- orderId : String
65+
- customerId : String
66+
- shippingAddress : String
67+
- amount : BigDecimal
68+
+ Order(orderId : String, customerId : String, shippingAddress : String, amount : BigDecimal)
69+
+ orderId() : String
70+
+ customerId() : String
71+
+ shippingAddress() : String
72+
+ amount() : BigDecimal
73+
}
74+
class OrderRejectedException {
75+
+ OrderRejectedException(message : String)
76+
}
77+
class App {
78+
+ App()
79+
+ main(args : String[]) : void
80+
}
81+
}
82+
CustomerUpdatedEvent --> CustomerState
83+
EventBus --> "*" EventListener
84+
CustomerService --> EventBus
85+
CustomerService --> "*" CustomerState
86+
CustomerService ..> CustomerUpdatedEvent : publishes
87+
CustomerReplica --> "*" CustomerState
88+
CustomerReplica ..> CustomerUpdatedEvent : applies
89+
CustomerReplica ..|> EventListener
90+
OrderService --> CustomerReplica
91+
OrderService ..> EventBus : subscribes
92+
OrderService ..> Order : creates
93+
OrderService ..> OrderRejectedException : throws
94+
OrderRejectedException --|> RuntimeException
95+
App ..> EventBus
96+
App ..> CustomerService
97+
App ..> OrderService
98+
@enduml

0 commit comments

Comments
 (0)