Skip to content

Commit 1cb094e

Browse files
committed
fix: align the event bus contract with the code and extend the demo
publish rejects null, exact-class dispatch is documented, the sequence counters are plain longs, and the demo shows a credit limit change flowing through the replica. The class diagram is a rendered PNG.
1 parent c92197c commit 1cb094e

11 files changed

Lines changed: 94 additions & 25 deletions

File tree

‎event-carried-state-transfer/README.md‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ sequenceDiagram
5757
OS->>R: apply (version 1 <= 2, ignored)
5858
```
5959

60+
![Event-Carried State Transfer class diagram](./etc/event-carried-state-transfer.urm.png)
61+
6062
## Programmatic Example of Event-Carried State Transfer Pattern in Java
6163

6264
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.
@@ -116,7 +118,7 @@ public CustomerState changeShippingAddress(String customerId, String newAddress)
116118

117119
private CustomerState store(CustomerState state) {
118120
customers.put(state.customerId(), state);
119-
var event = new CustomerUpdatedEvent(eventSequence.incrementAndGet(), Instant.now(), state);
121+
var event = new CustomerUpdatedEvent(++eventSequence, Instant.now(), state);
120122
bus.publish(event);
121123
return state;
122124
}
@@ -166,13 +168,13 @@ public Order placeOrder(String customerId, BigDecimal amount) {
166168
"Amount " + amount + " exceeds credit limit " + customer.creditLimit() + " of " + customerId);
167169
}
168170
return new Order(
169-
"ORD-" + orderSequence.incrementAndGet(), customerId, customer.shippingAddress(), amount);
171+
"ORD-" + ++orderSequence, customerId, customer.shippingAddress(), amount);
170172
}
171173
```
172174

173175
6. **The demo**
174176

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.
177+
`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 shows the replica enforcing the credit limit. It then brings the customer service back and raises that limit: the new limit travels inside the event, and the order that was just rejected is accepted from the replica alone, without a single call back to the producer.
176178

177179
```java
178180
var bus = new EventBus();
@@ -188,6 +190,10 @@ var order = orderService.placeOrder("C-1", new BigDecimal("120.00")); // succeed
188190

189191
bus.publish(new CustomerUpdatedEvent(99, Instant.now(), staleVersionOne)); // ignored
190192
tryToOrder(orderService, "C-1", new BigDecimal("900.00")); // rejected, above the replicated limit
193+
194+
customerService.restart();
195+
customerService.changeCreditLimit("C-1", new BigDecimal("1500.00")); // the event carries the new limit
196+
tryToOrder(orderService, "C-1", new BigDecimal("900.00")); // accepted now, still only from the replica
191197
```
192198

193199
Program output:
@@ -217,12 +223,17 @@ INFO App -- Order service replica: C-1 version 2 at '42 Ocean Avenue, Porto' wit
217223
INFO App -- --- Step 4: the replica is enough to enforce business rules ---
218224
WARN App -- Order rejected: Amount 900.00 exceeds credit limit 500.00 of C-1
219225
WARN App -- Order rejected: Unknown customer C-2 in replica
226+
INFO App -- --- Step 5: a new credit limit travels in the event and unblocks the rejected order ---
227+
INFO CustomerService -- Customer service is back online
228+
INFO CustomerService -- Customer C-1 gets a credit limit of 1500.00
229+
INFO CustomerService -- Publishing event 3 with the full state of C-1 (version 3)
230+
INFO EventBus -- Publishing CustomerUpdatedEvent to 1 subscriber(s)
231+
INFO CustomerReplica -- Replica updated from event 3: C-1 is now at version 3 with address '42 Ocean Avenue, Porto' and limit 1500.00
232+
INFO App -- Order service replica: C-1 version 3 at '42 Ocean Avenue, Porto' with limit 1500.00
233+
INFO OrderService -- Accepted ORD-2 for C-1 (900.00) shipping to '42 Ocean Avenue, Porto' using replica version 3
234+
INFO App -- Order ORD-2 accepted
220235
```
221236

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-
226237
## When to Use the Event-Carried State Transfer Pattern in Java
227238

228239
* Consumers need data owned by another service on every request and a synchronous call would add latency, load, or a hard availability dependency.
111 KB
Loading

‎event-carried-state-transfer/etc/event-carried-state-transfer.urm.puml‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,15 @@ package com.iluwatar.eventcarriedstatetransfer {
3636
class CustomerService {
3737
- customers : Map<String, CustomerState>
3838
- bus : EventBus
39-
- eventSequence : AtomicLong
39+
- eventSequence : long
4040
- online : boolean
4141
+ CustomerService(bus : EventBus)
4242
+ register(customerId : String, name : String, shippingAddress : String, creditLimit : BigDecimal) : CustomerState
4343
+ changeShippingAddress(customerId : String, newAddress : String) : CustomerState
4444
+ changeCreditLimit(customerId : String, newLimit : BigDecimal) : CustomerState
4545
+ findCustomer(customerId : String) : Optional<CustomerState>
4646
+ shutdown() : void
47+
+ restart() : void
4748
+ isOnline() : boolean
4849
}
4950
class CustomerReplica {
@@ -55,7 +56,7 @@ package com.iluwatar.eventcarriedstatetransfer {
5556
}
5657
class OrderService {
5758
- replica : CustomerReplica
58-
- orderSequence : AtomicLong
59+
- orderSequence : long
5960
+ OrderService(bus : EventBus)
6061
+ placeOrder(customerId : String, amount : BigDecimal) : Order
6162
+ replica() : CustomerReplica
@@ -86,8 +87,8 @@ CustomerService --> "*" CustomerState
8687
CustomerService ..> CustomerUpdatedEvent : publishes
8788
CustomerReplica --> "*" CustomerState
8889
CustomerReplica ..> CustomerUpdatedEvent : applies
89-
CustomerReplica ..|> EventListener
9090
OrderService --> CustomerReplica
91+
OrderService ..> EventListener : adapts the replica
9192
OrderService ..> EventBus : subscribes
9293
OrderService ..> Order : creates
9394
OrderService ..> OrderRejectedException : throws

‎event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/App.java‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@
4141
*
4242
* <p>The demo registers a customer and changes the address, showing the replica following each
4343
* event. It then takes the customer service offline and places an order anyway, purely from the
44-
* replica. A stale event is published to show that the replica ignores it, and finally an order
45-
* above the replicated credit limit is rejected.
44+
* replica. A stale event is published to show that the replica ignores it, and an order above the
45+
* replicated credit limit is rejected. Finally the customer service comes back and raises that
46+
* credit limit: the new state travels in the event, and the order that was just rejected is
47+
* accepted from the replica alone.
4648
*/
4749
@Slf4j
4850
public class App {
@@ -86,6 +88,13 @@ public static void main(String[] args) {
8688
LOGGER.info("--- Step 4: the replica is enough to enforce business rules ---");
8789
tryToOrder(orderService, "C-1", new BigDecimal("900.00"));
8890
tryToOrder(orderService, "C-2", new BigDecimal("10.00"));
91+
92+
LOGGER.info(
93+
"--- Step 5: a new credit limit travels in the event and unblocks the rejected order ---");
94+
customerService.restart();
95+
customerService.changeCreditLimit("C-1", new BigDecimal("1500.00"));
96+
logReplica(orderService, "C-1");
97+
tryToOrder(orderService, "C-1", new BigDecimal("900.00"));
8998
}
9099

91100
/**

‎event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerReplica.java‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
*/
2525
package com.iluwatar.eventcarriedstatetransfer;
2626

27-
import java.util.HashMap;
27+
import java.util.LinkedHashMap;
2828
import java.util.Map;
2929
import java.util.Optional;
3030
import lombok.extern.slf4j.Slf4j;
@@ -35,11 +35,13 @@
3535
* <p>Because each event carries the full state, applying one is a simple upsert. The version
3636
* carried by the state guards against events that arrive late or twice: an event is ignored unless
3737
* its version is newer than what the replica already holds.
38+
*
39+
* <p>Not thread-safe; the demo drives it from a single thread.
3840
*/
3941
@Slf4j
4042
public class CustomerReplica {
4143

42-
private final Map<String, CustomerState> customers = new HashMap<>();
44+
private final Map<String, CustomerState> customers = new LinkedHashMap<>();
4345

4446
/**
4547
* Applies an event to the replica.

‎event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerService.java‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
import java.util.LinkedHashMap;
3030
import java.util.Map;
3131
import java.util.Optional;
32-
import java.util.concurrent.atomic.AtomicLong;
3332
import lombok.extern.slf4j.Slf4j;
3433

3534
/**
@@ -39,13 +38,15 @@
3938
* {@link CustomerUpdatedEvent} that carries the customer's complete new state. Consumers never need
4039
* to query this service to act on the change, which is demonstrated by taking it offline in the
4140
* demo while orders keep flowing.
41+
*
42+
* <p>Not thread-safe; the demo drives it from a single thread.
4243
*/
4344
@Slf4j
4445
public class CustomerService {
4546

4647
private final Map<String, CustomerState> customers = new LinkedHashMap<>();
4748
private final EventBus bus;
48-
private final AtomicLong eventSequence = new AtomicLong();
49+
private long eventSequence;
4950
private boolean online = true;
5051

5152
/**
@@ -118,6 +119,12 @@ public void shutdown() {
118119
LOGGER.warn("Customer service is going offline");
119120
}
120121

122+
/** Ends the outage: direct queries are answered again. */
123+
public void restart() {
124+
online = true;
125+
LOGGER.info("Customer service is back online");
126+
}
127+
121128
/** Whether direct queries are currently answered. */
122129
public boolean isOnline() {
123130
return online;
@@ -133,7 +140,7 @@ private CustomerState existing(String customerId) {
133140

134141
private CustomerState store(CustomerState state) {
135142
customers.put(state.customerId(), state);
136-
var event = new CustomerUpdatedEvent(eventSequence.incrementAndGet(), Instant.now(), state);
143+
var event = new CustomerUpdatedEvent(++eventSequence, Instant.now(), state);
137144
LOGGER.info(
138145
"Publishing event {} with the full state of {} (version {})",
139146
event.eventId(),

‎event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/EventBus.java‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.util.HashMap;
2929
import java.util.List;
3030
import java.util.Map;
31+
import java.util.Objects;
3132
import lombok.extern.slf4j.Slf4j;
3233

3334
/**
@@ -45,6 +46,9 @@ public class EventBus {
4546
/**
4647
* Registers a listener for events of the given class.
4748
*
49+
* <p>Dispatch is by exact runtime class, so the listener receives only events whose class is
50+
* exactly {@code eventType}, never those of a subclass.
51+
*
4852
* @param eventType the class of events to receive
4953
* @param listener the listener to notify
5054
* @param <E> the event type
@@ -58,9 +62,14 @@ public <E> void subscribe(Class<E> eventType, EventListener<? super E> listener)
5862
/**
5963
* Delivers the event to every listener subscribed to its class.
6064
*
65+
* <p>Only listeners subscribed to the event's exact runtime class are notified; a listener
66+
* subscribed to a supertype of the event does not receive it.
67+
*
6168
* @param event the event to publish
69+
* @throws NullPointerException if the event is {@code null}
6270
*/
6371
public void publish(Object event) {
72+
Objects.requireNonNull(event, "event");
6473
var subscribers = listeners.getOrDefault(event.getClass(), List.of());
6574
if (subscribers.isEmpty()) {
6675
LOGGER.warn("No subscribers for {}", event.getClass().getSimpleName());

‎event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/OrderService.java‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
package com.iluwatar.eventcarriedstatetransfer;
2626

2727
import java.math.BigDecimal;
28-
import java.util.concurrent.atomic.AtomicLong;
2928
import lombok.extern.slf4j.Slf4j;
3029

3130
/**
@@ -34,12 +33,14 @@
3433
* <p>The order service subscribes its {@link CustomerReplica} to customer events and afterwards
3534
* answers every order using only that replica. It has no reference to the customer service at all,
3635
* so it keeps working while the customer service is down and it never adds load to it.
36+
*
37+
* <p>Not thread-safe; the demo drives it from a single thread.
3738
*/
3839
@Slf4j
3940
public class OrderService {
4041

4142
private final CustomerReplica replica = new CustomerReplica();
42-
private final AtomicLong orderSequence = new AtomicLong();
43+
private long orderSequence;
4344

4445
/**
4546
* Creates the service and subscribes its replica to customer events.
@@ -74,12 +75,7 @@ public Order placeOrder(String customerId, BigDecimal amount) {
7475
+ " of "
7576
+ customerId);
7677
}
77-
var order =
78-
new Order(
79-
"ORD-" + orderSequence.incrementAndGet(),
80-
customerId,
81-
customer.shippingAddress(),
82-
amount);
78+
var order = new Order("ORD-" + ++orderSequence, customerId, customer.shippingAddress(), amount);
8379
LOGGER.info(
8480
"Accepted {} for {} ({}) shipping to '{}' using replica version {}",
8581
order.orderId(),

‎event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerServiceTest.java‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,15 @@ void refusesDirectLookupsWhenOffline() {
100100
var thrown = assertThrows(IllegalStateException.class, () -> service.findCustomer("C-1"));
101101
assertEquals("customer service is offline", thrown.getMessage());
102102
}
103+
104+
@Test
105+
void answersDirectLookupsAgainAfterARestart() {
106+
service.register("C-1", "Alice", "Lisbon", new BigDecimal("500.00"));
107+
service.shutdown();
108+
109+
service.restart();
110+
111+
assertTrue(service.isOnline());
112+
assertEquals("Alice", service.findCustomer("C-1").orElseThrow().name());
113+
}
103114
}

‎event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/EventBusTest.java‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
2828
import static org.junit.jupiter.api.Assertions.assertEquals;
29+
import static org.junit.jupiter.api.Assertions.assertThrows;
2930
import static org.junit.jupiter.api.Assertions.assertTrue;
3031

3132
import java.util.ArrayList;
@@ -62,6 +63,13 @@ void publishingWithoutSubscribersIsHarmless() {
6263
assertDoesNotThrow(() -> bus.publish("nobody listens"));
6364
}
6465

66+
@Test
67+
void rejectsNullEvents() {
68+
var exception = assertThrows(NullPointerException.class, () -> bus.publish(null));
69+
70+
assertEquals("event", exception.getMessage());
71+
}
72+
6573
@Test
6674
void deliversInSubscriptionOrder() {
6775
var order = new ArrayList<String>();

0 commit comments

Comments
 (0)