diff --git a/event-carried-state-transfer/README.md b/event-carried-state-transfer/README.md new file mode 100644 index 000000000000..59e1158f926c --- /dev/null +++ b/event-carried-state-transfer/README.md @@ -0,0 +1,269 @@ +--- +title: "Event-Carried State Transfer Pattern in Java: Keeping Services Autonomous with Stateful Events" +shortTitle: Event-Carried State Transfer +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." +category: Messaging +language: en +tag: + - Data transfer + - Decoupling + - Event-driven + - Messaging + - Microservices +--- + +## Also known as + +* ECST +* Stateful events +* Fat events + +## Intent of Event-Carried State Transfer Design Pattern + +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. + +## Detailed Explanation of Event-Carried State Transfer Pattern with Real-World Examples + +Real-world example + +> 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. + +In plain words + +> Do not just tell other services that something changed, send them the whole new state so they never have to ask. + +Martin Fowler says + +> 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. + +Sequence diagram + +```mermaid +sequenceDiagram + participant CS as Customer service (producer) + participant Bus as Event bus + participant OS as Order service (consumer) + participant R as Customer replica + + CS->>CS: change address, version 1 -> 2 + CS->>Bus: CustomerUpdatedEvent(full state, version 2) + Bus->>OS: deliver event + OS->>R: apply (version 2 > 1, upsert) + Note over CS: customer service goes offline + OS->>R: find customer C-1 + R-->>OS: state version 2 (address, credit limit) + OS-->>OS: order accepted, ships to the replicated address + Bus->>OS: stale CustomerUpdatedEvent(version 1) + OS->>R: apply (version 1 <= 2, ignored) +``` + +## Programmatic Example of Event-Carried State Transfer Pattern in Java + +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. + +1. **The state and the event that carries it** + +`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. + +```java +public record CustomerState( + String customerId, String name, String shippingAddress, BigDecimal creditLimit, long version) { + + public CustomerState withShippingAddress(String newAddress) { + return new CustomerState(customerId, name, newAddress, creditLimit, version + 1); + } + + public CustomerState withCreditLimit(BigDecimal newLimit) { + return new CustomerState(customerId, name, shippingAddress, newLimit, version + 1); + } +} + +public record CustomerUpdatedEvent(long eventId, Instant occurredAt, CustomerState state) {} +``` + +2. **The channel** + +`EventBus` is a synchronous publish/subscribe channel keyed by event class. In production this role is played by a message broker. + +```java +public void subscribe(Class eventType, EventListener listener) { + var subscribers = listeners.computeIfAbsent(eventType, key -> new ArrayList<>()); + subscribers.add(listener); + LOGGER.info("Subscriber {} registered for {}", subscribers.size(), eventType.getSimpleName()); +} + +public void publish(Object event) { + var subscribers = listeners.getOrDefault(event.getClass(), List.of()); + if (subscribers.isEmpty()) { + LOGGER.warn("No subscribers for {}", event.getClass().getSimpleName()); + return; + } + for (var listener : subscribers) { + deliver(listener, event); + } +} +``` + +3. **The producer** + +`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. + +```java +public CustomerState changeShippingAddress(String customerId, String newAddress) { + LOGGER.info("Customer {} moves to {}", customerId, newAddress); + return store(existing(customerId).withShippingAddress(newAddress)); +} + +private CustomerState store(CustomerState state) { + customers.put(state.customerId(), state); + var event = new CustomerUpdatedEvent(eventSequence.incrementAndGet(), Instant.now(), state); + bus.publish(event); + return state; +} + +public Optional findCustomer(String customerId) { + if (!online) { + throw new IllegalStateException("customer service is offline"); + } + return Optional.ofNullable(customers.get(customerId)); +} +``` + +4. **The consumer's replica** + +`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. + +```java +public boolean apply(CustomerUpdatedEvent event) { + var incoming = event.state(); + var current = customers.get(incoming.customerId()); + if (current != null && current.version() >= incoming.version()) { + LOGGER.info("Ignoring event {} for {}: version {} is not newer than replica version {}", ...); + return false; + } + customers.put(incoming.customerId(), incoming); + return true; +} +``` + +5. **The consumer** + +`OrderService` subscribes its replica to the bus and afterwards reads only from the replica. It holds no reference to the customer service. + +```java +public OrderService(EventBus bus) { + bus.subscribe(CustomerUpdatedEvent.class, replica::apply); +} + +public Order placeOrder(String customerId, BigDecimal amount) { + var customer = + replica + .find(customerId) + .orElseThrow( + () -> new OrderRejectedException("Unknown customer " + customerId + " in replica")); + if (amount.compareTo(customer.creditLimit()) > 0) { + throw new OrderRejectedException( + "Amount " + amount + " exceeds credit limit " + customer.creditLimit() + " of " + customerId); + } + return new Order( + "ORD-" + orderSequence.incrementAndGet(), customerId, customer.shippingAddress(), amount); +} +``` + +6. **The demo** + +`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. + +```java +var bus = new EventBus(); +var customerService = new CustomerService(bus); +var orderService = new OrderService(bus); + +customerService.register("C-1", "Alice", "1 Harbour Street, Lisbon", new BigDecimal("500.00")); +customerService.changeShippingAddress("C-1", "42 Ocean Avenue, Porto"); + +customerService.shutdown(); +lookUpDirectly(customerService, "C-1"); // fails, the producer is down +var order = orderService.placeOrder("C-1", new BigDecimal("120.00")); // succeeds from the replica + +bus.publish(new CustomerUpdatedEvent(99, Instant.now(), staleVersionOne)); // ignored +tryToOrder(orderService, "C-1", new BigDecimal("900.00")); // rejected, above the replicated limit +``` + +Program output: + +``` +INFO EventBus -- Subscriber 1 registered for CustomerUpdatedEvent +INFO App -- --- Step 1: every customer change is published with the full customer state --- +INFO CustomerService -- Registering customer C-1 (Alice) +INFO CustomerService -- Publishing event 1 with the full state of C-1 (version 1) +INFO EventBus -- Publishing CustomerUpdatedEvent to 1 subscriber(s) +INFO CustomerReplica -- Replica updated from event 1: C-1 is now at version 1 with address '1 Harbour Street, Lisbon' and limit 500.00 +INFO App -- Order service replica: C-1 version 1 at '1 Harbour Street, Lisbon' with limit 500.00 +INFO CustomerService -- Customer C-1 moves to 42 Ocean Avenue, Porto +INFO CustomerService -- Publishing event 2 with the full state of C-1 (version 2) +INFO EventBus -- Publishing CustomerUpdatedEvent to 1 subscriber(s) +INFO CustomerReplica -- Replica updated from event 2: C-1 is now at version 2 with address '42 Ocean Avenue, Porto' and limit 500.00 +INFO App -- Order service replica: C-1 version 2 at '42 Ocean Avenue, Porto' with limit 500.00 +INFO App -- --- Step 2: the customer service goes offline, orders still flow from the replica --- +WARN CustomerService -- Customer service is going offline +WARN App -- Direct lookup of C-1 failed: customer service is offline +INFO OrderService -- Accepted ORD-1 for C-1 (120.00) shipping to '42 Ocean Avenue, Porto' using replica version 2 +INFO App -- ORD-1 ships to '42 Ocean Avenue, Porto' without asking the customer service +INFO App -- --- Step 3: a stale event arrives late and the replica ignores it --- +INFO EventBus -- Publishing CustomerUpdatedEvent to 1 subscriber(s) +INFO CustomerReplica -- Ignoring event 99 for C-1: version 1 is not newer than replica version 2 +INFO App -- Order service replica: C-1 version 2 at '42 Ocean Avenue, Porto' with limit 500.00 +INFO App -- --- Step 4: the replica is enough to enforce business rules --- +WARN App -- Order rejected: Amount 900.00 exceeds credit limit 500.00 of C-1 +WARN App -- Order rejected: Unknown customer C-2 in replica +``` + +## Class diagram + +See [event-carried-state-transfer.urm.puml](./etc/event-carried-state-transfer.urm.puml) for the PlantUML class diagram. + +## When to Use the Event-Carried State Transfer Pattern in Java + +* Consumers need data owned by another service on every request and a synchronous call would add latency, load, or a hard availability dependency. +* The producer must stay available and responsive regardless of how many consumers depend on its data. +* Consumers can tolerate eventual consistency, reading data that is a few events behind the producer. +* Several services need their own view of the same data, possibly stored in different shapes. + +## Real-World Applications of Event-Carried State Transfer Pattern in Java + +* Kafka topics that carry full entity snapshots, consumed by services that build local materialized views. +* Change data capture pipelines such as Debezium, which stream the complete row state after each database change. +* Product catalogue or customer master data replicated into search, pricing, and fulfilment services in e-commerce platforms. + +## Benefits and Trade-offs of Event-Carried State Transfer Pattern + +Benefits: + +* Consumers are autonomous: they answer from local data and keep working while the producer is down. +* The producer is not queried by consumers, so its load does not grow with the number of consumers. +* Every event is self-contained, which makes consumers simple to write and test. + +Trade-offs: + +* Eventual consistency: a consumer may act on data that is one or more events behind. +* Data is duplicated across services and every consumer has to store what it needs. +* Events are larger than plain notifications, and their schema has to be versioned and evolved carefully. +* Consumers must handle out-of-order and duplicated deliveries, for example with the version check shown here. + +## Related Java Design Patterns + +* [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. +* [Publish-Subscribe](../publish-subscribe): the delivery mechanism the state-carrying events ride on. +* [Event Sourcing](../event-sourcing): stores events as the system of record and rebuilds state by replaying them. +* [Microservices Messaging](../microservices-messaging): asynchronous communication between services, which ECST relies on. +* [Command Query Responsibility Segregation](../command-query-responsibility-segregation): read models fed by ECST events are a common way to build the query side. + +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. + +## References and Credits + +* [What do you mean by "Event-Driven"? (Martin Fowler)](https://martinfowler.com/articles/201701-event-driven.html) +* [Stateful Event Pattern (Graham Brooks)](https://www.grahambrooks.com/event-driven-architecture/patterns/stateful-event-pattern/) +* [The Event-Carried State Transfer Pattern (itnext)](https://itnext.io/the-event-carried-state-transfer-pattern-aae49715bb7f) +* [Microservices Patterns: With examples in Java](https://amzn.to/3xaZwk0) diff --git a/event-carried-state-transfer/etc/event-carried-state-transfer.urm.puml b/event-carried-state-transfer/etc/event-carried-state-transfer.urm.puml new file mode 100644 index 000000000000..08f2bca14a43 --- /dev/null +++ b/event-carried-state-transfer/etc/event-carried-state-transfer.urm.puml @@ -0,0 +1,98 @@ +@startuml +package com.iluwatar.eventcarriedstatetransfer { + class CustomerState { + - customerId : String + - name : String + - shippingAddress : String + - creditLimit : BigDecimal + - version : long + + CustomerState(customerId : String, name : String, shippingAddress : String, creditLimit : BigDecimal, version : long) + + customerId() : String + + name() : String + + shippingAddress() : String + + creditLimit() : BigDecimal + + version() : long + + withShippingAddress(newAddress : String) : CustomerState + + withCreditLimit(newLimit : BigDecimal) : CustomerState + } + class CustomerUpdatedEvent { + - eventId : long + - occurredAt : Instant + - state : CustomerState + + CustomerUpdatedEvent(eventId : long, occurredAt : Instant, state : CustomerState) + + eventId() : long + + occurredAt() : Instant + + state() : CustomerState + } + interface EventListener { + + onEvent(event : E) : void {abstract} + } + class EventBus { + - listeners : Map, List>> + + EventBus() + + subscribe(eventType : Class, listener : EventListener) : void + + publish(event : Object) : void + } + class CustomerService { + - customers : Map + - bus : EventBus + - eventSequence : AtomicLong + - online : boolean + + CustomerService(bus : EventBus) + + register(customerId : String, name : String, shippingAddress : String, creditLimit : BigDecimal) : CustomerState + + changeShippingAddress(customerId : String, newAddress : String) : CustomerState + + changeCreditLimit(customerId : String, newLimit : BigDecimal) : CustomerState + + findCustomer(customerId : String) : Optional + + shutdown() : void + + isOnline() : boolean + } + class CustomerReplica { + - customers : Map + + CustomerReplica() + + apply(event : CustomerUpdatedEvent) : boolean + + find(customerId : String) : Optional + + size() : int + } + class OrderService { + - replica : CustomerReplica + - orderSequence : AtomicLong + + OrderService(bus : EventBus) + + placeOrder(customerId : String, amount : BigDecimal) : Order + + replica() : CustomerReplica + } + class Order { + - orderId : String + - customerId : String + - shippingAddress : String + - amount : BigDecimal + + Order(orderId : String, customerId : String, shippingAddress : String, amount : BigDecimal) + + orderId() : String + + customerId() : String + + shippingAddress() : String + + amount() : BigDecimal + } + class OrderRejectedException { + + OrderRejectedException(message : String) + } + class App { + + App() + + main(args : String[]) : void + } +} +CustomerUpdatedEvent --> CustomerState +EventBus --> "*" EventListener +CustomerService --> EventBus +CustomerService --> "*" CustomerState +CustomerService ..> CustomerUpdatedEvent : publishes +CustomerReplica --> "*" CustomerState +CustomerReplica ..> CustomerUpdatedEvent : applies +CustomerReplica ..|> EventListener +OrderService --> CustomerReplica +OrderService ..> EventBus : subscribes +OrderService ..> Order : creates +OrderService ..> OrderRejectedException : throws +OrderRejectedException --|> RuntimeException +App ..> EventBus +App ..> CustomerService +App ..> OrderService +@enduml diff --git a/event-carried-state-transfer/pom.xml b/event-carried-state-transfer/pom.xml new file mode 100644 index 000000000000..530176433922 --- /dev/null +++ b/event-carried-state-transfer/pom.xml @@ -0,0 +1,70 @@ + + + + 4.0.0 + + com.iluwatar + java-design-patterns + 1.26.0-SNAPSHOT + + event-carried-state-transfer + + + org.slf4j + slf4j-api + + + ch.qos.logback + logback-classic + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + + + + + com.iluwatar.eventcarriedstatetransfer.App + + + + + + + + + diff --git a/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/App.java b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/App.java new file mode 100644 index 000000000000..98ee9f57711c --- /dev/null +++ b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/App.java @@ -0,0 +1,131 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +import java.math.BigDecimal; +import java.time.Instant; +import lombok.extern.slf4j.Slf4j; + +/** + * Event-Carried State Transfer (ECST) is an event-driven pattern in which every event carries the + * complete state of the entity that changed. Consumers keep their own local copy of that state and + * serve their requests from it, so they neither call the producer back nor stop working when the + * producer is unavailable. + * + *

The building blocks are a producer ({@link CustomerService}) that publishes {@link + * CustomerUpdatedEvent}s containing the full {@link CustomerState}, a channel ({@link EventBus}), + * and a consumer ({@link OrderService}) that keeps a {@link CustomerReplica} up to date and reads + * only from it. + * + *

The demo registers a customer and changes the address, showing the replica following each + * event. It then takes the customer service offline and places an order anyway, purely from the + * replica. A stale event is published to show that the replica ignores it, and finally an order + * above the replicated credit limit is rejected. + */ +@Slf4j +public class App { + + /** + * Program entry point. + * + * @param args command line arguments, not used + */ + public static void main(String[] args) { + var bus = new EventBus(); + var customerService = new CustomerService(bus); + var orderService = new OrderService(bus); + + LOGGER.info("--- Step 1: every customer change is published with the full customer state ---"); + customerService.register("C-1", "Alice", "1 Harbour Street, Lisbon", new BigDecimal("500.00")); + logReplica(orderService, "C-1"); + customerService.changeShippingAddress("C-1", "42 Ocean Avenue, Porto"); + logReplica(orderService, "C-1"); + + LOGGER.info( + "--- Step 2: the customer service goes offline, orders still flow from the replica ---"); + customerService.shutdown(); + lookUpDirectly(customerService, "C-1"); + var order = orderService.placeOrder("C-1", new BigDecimal("120.00")); + LOGGER.info( + "{} ships to '{}' without asking the customer service", + order.orderId(), + order.shippingAddress()); + + LOGGER.info("--- Step 3: a stale event arrives late and the replica ignores it ---"); + var stale = + new CustomerUpdatedEvent( + 99, + Instant.now(), + new CustomerState( + "C-1", "Alice", "1 Harbour Street, Lisbon", new BigDecimal("500.00"), 1)); + bus.publish(stale); + logReplica(orderService, "C-1"); + + LOGGER.info("--- Step 4: the replica is enough to enforce business rules ---"); + tryToOrder(orderService, "C-1", new BigDecimal("900.00")); + tryToOrder(orderService, "C-2", new BigDecimal("10.00")); + } + + /** + * The call a consumer would have to make without the pattern; it fails while the producer is + * down. + */ + static void lookUpDirectly(CustomerService customerService, String customerId) { + try { + customerService + .findCustomer(customerId) + .ifPresent( + state -> + LOGGER.info( + "Direct lookup of {} answered version {}", customerId, state.version())); + } catch (IllegalStateException e) { + LOGGER.warn("Direct lookup of {} failed: {}", customerId, e.getMessage()); + } + } + + private static void logReplica(OrderService orderService, String customerId) { + orderService + .replica() + .find(customerId) + .ifPresent( + state -> + LOGGER.info( + "Order service replica: {} version {} at '{}' with limit {}", + state.customerId(), + state.version(), + state.shippingAddress(), + state.creditLimit())); + } + + /** Places an order and logs the outcome instead of failing the demo on a rejection. */ + static void tryToOrder(OrderService orderService, String customerId, BigDecimal amount) { + try { + var order = orderService.placeOrder(customerId, amount); + LOGGER.info("Order {} accepted", order.orderId()); + } catch (OrderRejectedException e) { + LOGGER.warn("Order rejected: {}", e.getMessage()); + } + } +} diff --git a/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerReplica.java b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerReplica.java new file mode 100644 index 000000000000..e6b1ed0c612c --- /dev/null +++ b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerReplica.java @@ -0,0 +1,87 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; + +/** + * The consumer's local copy of customer state, fed exclusively by {@link CustomerUpdatedEvent}s. + * + *

Because each event carries the full state, applying one is a simple upsert. The version + * carried by the state guards against events that arrive late or twice: an event is ignored unless + * its version is newer than what the replica already holds. + */ +@Slf4j +public class CustomerReplica { + + private final Map customers = new HashMap<>(); + + /** + * Applies an event to the replica. + * + * @param event the received event + * @return {@code true} if the replica was updated, {@code false} if the event was stale + */ + public boolean apply(CustomerUpdatedEvent event) { + var incoming = event.state(); + var current = customers.get(incoming.customerId()); + if (current != null && current.version() >= incoming.version()) { + LOGGER.info( + "Ignoring event {} for {}: version {} is not newer than replica version {}", + event.eventId(), + incoming.customerId(), + incoming.version(), + current.version()); + return false; + } + customers.put(incoming.customerId(), incoming); + LOGGER.info( + "Replica updated from event {}: {} is now at version {} with address '{}' and limit {}", + event.eventId(), + incoming.customerId(), + incoming.version(), + incoming.shippingAddress(), + incoming.creditLimit()); + return true; + } + + /** + * Reads a customer from the local copy. + * + * @param customerId the identifier of the customer + * @return the replicated state, if any event for the customer has been received + */ + public Optional find(String customerId) { + return Optional.ofNullable(customers.get(customerId)); + } + + /** Number of customers known to the replica. */ + public int size() { + return customers.size(); + } +} diff --git a/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerService.java b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerService.java new file mode 100644 index 000000000000..484bd2a8bc4d --- /dev/null +++ b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerService.java @@ -0,0 +1,145 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import lombok.extern.slf4j.Slf4j; + +/** + * The producer side of the pattern: the system of record for customers. + * + *

Every change to a customer is applied to the authoritative store and then announced with a + * {@link CustomerUpdatedEvent} that carries the customer's complete new state. Consumers never need + * to query this service to act on the change, which is demonstrated by taking it offline in the + * demo while orders keep flowing. + */ +@Slf4j +public class CustomerService { + + private final Map customers = new LinkedHashMap<>(); + private final EventBus bus; + private final AtomicLong eventSequence = new AtomicLong(); + private boolean online = true; + + /** + * Creates the service. + * + * @param bus the channel on which state events are published + */ + public CustomerService(EventBus bus) { + this.bus = bus; + } + + /** + * Registers a new customer and publishes its initial state. + * + * @param customerId the identifier of the customer + * @param name the customer's name + * @param shippingAddress the shipping address + * @param creditLimit the credit limit + * @return the stored state + */ + public CustomerState register( + String customerId, String name, String shippingAddress, BigDecimal creditLimit) { + var state = new CustomerState(customerId, name, shippingAddress, creditLimit, 1); + LOGGER.info("Registering customer {} ({})", customerId, name); + return store(state); + } + + /** + * Changes the shipping address of a customer and publishes the new state. + * + * @param customerId the identifier of the customer + * @param newAddress the new shipping address + * @return the stored state + */ + public CustomerState changeShippingAddress(String customerId, String newAddress) { + LOGGER.info("Customer {} moves to {}", customerId, newAddress); + return store(existing(customerId).withShippingAddress(newAddress)); + } + + /** + * Changes the credit limit of a customer and publishes the new state. + * + * @param customerId the identifier of the customer + * @param newLimit the new credit limit + * @return the stored state + */ + public CustomerState changeCreditLimit(String customerId, BigDecimal newLimit) { + LOGGER.info("Customer {} gets a credit limit of {}", customerId, newLimit); + return store(existing(customerId).withCreditLimit(newLimit)); + } + + /** + * Looks a customer up directly. This is the call consumers would have to make without the + * pattern, and it fails once the service is offline. + * + * @param customerId the identifier of the customer + * @return the current state, if the customer exists + * @throws IllegalStateException if the service has been shut down + */ + public Optional findCustomer(String customerId) { + if (!online) { + throw new IllegalStateException("customer service is offline"); + } + return Optional.ofNullable(customers.get(customerId)); + } + + /** Simulates an outage: direct queries fail until the service is back. */ + public void shutdown() { + online = false; + LOGGER.warn("Customer service is going offline"); + } + + /** Whether direct queries are currently answered. */ + public boolean isOnline() { + return online; + } + + private CustomerState existing(String customerId) { + var state = customers.get(customerId); + if (state == null) { + throw new IllegalArgumentException("Unknown customer: " + customerId); + } + return state; + } + + private CustomerState store(CustomerState state) { + customers.put(state.customerId(), state); + var event = new CustomerUpdatedEvent(eventSequence.incrementAndGet(), Instant.now(), state); + LOGGER.info( + "Publishing event {} with the full state of {} (version {})", + event.eventId(), + state.customerId(), + state.version()); + bus.publish(event); + return state; + } +} diff --git a/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerState.java b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerState.java new file mode 100644 index 000000000000..86ea9ecf318d --- /dev/null +++ b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerState.java @@ -0,0 +1,76 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * The complete state of a customer as the customer service knows it. + * + *

Every {@link CustomerUpdatedEvent} carries one of these, so a consumer that receives the event + * has everything it needs to serve its own requests without calling the customer service back. The + * {@code version} grows with every change and lets consumers recognise stale or duplicated events. + * + * @param customerId the identifier of the customer + * @param name the customer's name + * @param shippingAddress where orders for this customer are shipped + * @param creditLimit the maximum order amount the customer may place + * @param version monotonically increasing change counter, starts at 1 + */ +public record CustomerState( + String customerId, String name, String shippingAddress, BigDecimal creditLimit, long version) { + + /** Validates the state. */ + public CustomerState { + Objects.requireNonNull(customerId, "customerId"); + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(shippingAddress, "shippingAddress"); + Objects.requireNonNull(creditLimit, "creditLimit"); + if (version < 1) { + throw new IllegalArgumentException("version must be at least 1"); + } + } + + /** + * Returns a copy with a new shipping address and the next version. + * + * @param newAddress the new shipping address + * @return the updated state + */ + public CustomerState withShippingAddress(String newAddress) { + return new CustomerState(customerId, name, newAddress, creditLimit, version + 1); + } + + /** + * Returns a copy with a new credit limit and the next version. + * + * @param newLimit the new credit limit + * @return the updated state + */ + public CustomerState withCreditLimit(BigDecimal newLimit) { + return new CustomerState(customerId, name, shippingAddress, newLimit, version + 1); + } +} diff --git a/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerUpdatedEvent.java b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerUpdatedEvent.java new file mode 100644 index 000000000000..410f8c0256fc --- /dev/null +++ b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerUpdatedEvent.java @@ -0,0 +1,41 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +import java.time.Instant; + +/** + * Event published whenever a customer changes. + * + *

This is the heart of the pattern: instead of announcing only that a customer changed + * and forcing consumers to call back for the details, the event carries the customer's + * full {@link CustomerState}. Consumers store it locally and stay operational even + * when the customer service is unavailable. + * + * @param eventId sequence number assigned by the producer + * @param occurredAt when the change happened + * @param state the complete customer state after the change + */ +public record CustomerUpdatedEvent(long eventId, Instant occurredAt, CustomerState state) {} diff --git a/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/EventBus.java b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/EventBus.java new file mode 100644 index 000000000000..7bf876177a4f --- /dev/null +++ b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/EventBus.java @@ -0,0 +1,80 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; + +/** + * Minimal in-memory, synchronous publish/subscribe channel. + * + *

Producers publish events, subscribers register for an event class and receive every event of + * that class in subscription order. In production this role is played by a message broker such as + * Kafka or RabbitMQ; here it is kept synchronous so the pattern stays easy to follow and to test. + */ +@Slf4j +public class EventBus { + + private final Map, List>> listeners = new HashMap<>(); + + /** + * Registers a listener for events of the given class. + * + * @param eventType the class of events to receive + * @param listener the listener to notify + * @param the event type + */ + public void subscribe(Class eventType, EventListener listener) { + var subscribers = listeners.computeIfAbsent(eventType, key -> new ArrayList<>()); + subscribers.add(listener); + LOGGER.info("Subscriber {} registered for {}", subscribers.size(), eventType.getSimpleName()); + } + + /** + * Delivers the event to every listener subscribed to its class. + * + * @param event the event to publish + */ + public void publish(Object event) { + var subscribers = listeners.getOrDefault(event.getClass(), List.of()); + if (subscribers.isEmpty()) { + LOGGER.warn("No subscribers for {}", event.getClass().getSimpleName()); + return; + } + LOGGER.info( + "Publishing {} to {} subscriber(s)", event.getClass().getSimpleName(), subscribers.size()); + for (var listener : subscribers) { + deliver(listener, event); + } + } + + @SuppressWarnings("unchecked") + private static void deliver(EventListener listener, Object event) { + listener.onEvent((E) event); + } +} diff --git a/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/EventListener.java b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/EventListener.java new file mode 100644 index 000000000000..d715b985434d --- /dev/null +++ b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/EventListener.java @@ -0,0 +1,41 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +/** + * Receives events of one type from the {@link EventBus}. + * + * @param the event type + */ +@FunctionalInterface +public interface EventListener { + + /** + * Handles one event. + * + * @param event the delivered event + */ + void onEvent(E event); +} diff --git a/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/Order.java b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/Order.java new file mode 100644 index 000000000000..4b1037995b2f --- /dev/null +++ b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/Order.java @@ -0,0 +1,37 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +import java.math.BigDecimal; + +/** + * An order accepted by the {@link OrderService}. + * + * @param orderId the generated order identifier + * @param customerId the ordering customer + * @param shippingAddress the address taken from the customer replica at the time of ordering + * @param amount the order amount + */ +public record Order(String orderId, String customerId, String shippingAddress, BigDecimal amount) {} diff --git a/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/OrderRejectedException.java b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/OrderRejectedException.java new file mode 100644 index 000000000000..da0ff50be942 --- /dev/null +++ b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/OrderRejectedException.java @@ -0,0 +1,38 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +/** Thrown when the {@link OrderService} cannot accept an order based on its replicated data. */ +public class OrderRejectedException extends RuntimeException { + + /** + * Creates the exception. + * + * @param message why the order was rejected + */ + public OrderRejectedException(String message) { + super(message); + } +} diff --git a/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/OrderService.java b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/OrderService.java new file mode 100644 index 000000000000..081ab751514a --- /dev/null +++ b/event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/OrderService.java @@ -0,0 +1,97 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +import java.math.BigDecimal; +import java.util.concurrent.atomic.AtomicLong; +import lombok.extern.slf4j.Slf4j; + +/** + * The consumer side of the pattern. + * + *

The order service subscribes its {@link CustomerReplica} to customer events and afterwards + * answers every order using only that replica. It has no reference to the customer service at all, + * so it keeps working while the customer service is down and it never adds load to it. + */ +@Slf4j +public class OrderService { + + private final CustomerReplica replica = new CustomerReplica(); + private final AtomicLong orderSequence = new AtomicLong(); + + /** + * Creates the service and subscribes its replica to customer events. + * + * @param bus the channel that delivers customer events + */ + public OrderService(EventBus bus) { + bus.subscribe(CustomerUpdatedEvent.class, replica::apply); + } + + /** + * Places an order using the replicated customer state. + * + * @param customerId the ordering customer + * @param amount the order amount + * @return the accepted order + * @throws OrderRejectedException if the customer is unknown to the replica or the amount exceeds + * the replicated credit limit + */ + public Order placeOrder(String customerId, BigDecimal amount) { + var customer = + replica + .find(customerId) + .orElseThrow( + () -> new OrderRejectedException("Unknown customer " + customerId + " in replica")); + if (amount.compareTo(customer.creditLimit()) > 0) { + throw new OrderRejectedException( + "Amount " + + amount + + " exceeds credit limit " + + customer.creditLimit() + + " of " + + customerId); + } + var order = + new Order( + "ORD-" + orderSequence.incrementAndGet(), + customerId, + customer.shippingAddress(), + amount); + LOGGER.info( + "Accepted {} for {} ({}) shipping to '{}' using replica version {}", + order.orderId(), + customerId, + amount, + order.shippingAddress(), + customer.version()); + return order; + } + + /** The local copy of customer state this service works from. */ + public CustomerReplica replica() { + return replica; + } +} diff --git a/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/AppTest.java b/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/AppTest.java new file mode 100644 index 000000000000..989a75eea778 --- /dev/null +++ b/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/AppTest.java @@ -0,0 +1,62 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; + +class AppTest { + + @Test + void shouldBeInstantiable() { + assertNotNull(new App(), "App should be instantiable"); + } + + @Test + void shouldLaunchApp() { + assertDoesNotThrow(() -> App.main(new String[] {})); + } + + @Test + void directLookupSucceedsWhileTheCustomerServiceIsOnline() { + var customerService = new CustomerService(new EventBus()); + customerService.register("C-1", "Alice", "Lisbon", new BigDecimal("500.00")); + + assertDoesNotThrow(() -> App.lookUpDirectly(customerService, "C-1")); + } + + @Test + void tryToOrderLogsAcceptedOrders() { + var bus = new EventBus(); + var customerService = new CustomerService(bus); + var orderService = new OrderService(bus); + customerService.register("C-1", "Alice", "Lisbon", new BigDecimal("500.00")); + + assertDoesNotThrow(() -> App.tryToOrder(orderService, "C-1", new BigDecimal("10.00"))); + } +} diff --git a/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerReplicaTest.java b/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerReplicaTest.java new file mode 100644 index 000000000000..1428231e2661 --- /dev/null +++ b/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerReplicaTest.java @@ -0,0 +1,88 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class CustomerReplicaTest { + + private final CustomerReplica replica = new CustomerReplica(); + + @Test + void appliesTheFirstEventForACustomer() { + assertTrue(replica.apply(event(1, state("Lisbon", 1)))); + + assertEquals(1, replica.size()); + assertEquals("Lisbon", replica.find("C-1").orElseThrow().shippingAddress()); + } + + @Test + void appliesNewerVersions() { + replica.apply(event(1, state("Lisbon", 1))); + + assertTrue(replica.apply(event(2, state("Porto", 2)))); + + assertEquals("Porto", replica.find("C-1").orElseThrow().shippingAddress()); + assertEquals(2, replica.find("C-1").orElseThrow().version()); + } + + @Test + void ignoresOlderVersionsThatArriveLate() { + replica.apply(event(2, state("Porto", 2))); + + assertFalse(replica.apply(event(1, state("Lisbon", 1)))); + + assertEquals("Porto", replica.find("C-1").orElseThrow().shippingAddress()); + } + + @Test + void ignoresDuplicateDeliveries() { + replica.apply(event(1, state("Lisbon", 1))); + + assertFalse(replica.apply(event(1, state("Lisbon", 1)))); + + assertEquals(1, replica.size()); + } + + @Test + void unknownCustomersAreAbsent() { + assertTrue(replica.find("C-9").isEmpty()); + assertEquals(0, replica.size()); + } + + private static CustomerState state(String address, long version) { + return new CustomerState("C-1", "Alice", address, new BigDecimal("500.00"), version); + } + + private static CustomerUpdatedEvent event(long id, CustomerState state) { + return new CustomerUpdatedEvent(id, Instant.EPOCH, state); + } +} diff --git a/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerServiceTest.java b/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerServiceTest.java new file mode 100644 index 000000000000..79060157aa7f --- /dev/null +++ b/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerServiceTest.java @@ -0,0 +1,103 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class CustomerServiceTest { + + private final EventBus bus = new EventBus(); + private final List published = new ArrayList<>(); + private final CustomerService service = new CustomerService(bus); + + @BeforeEach + void subscribe() { + bus.subscribe(CustomerUpdatedEvent.class, published::add); + } + + @Test + void registrationPublishesTheFullInitialState() { + var state = service.register("C-1", "Alice", "Lisbon", new BigDecimal("500.00")); + + assertEquals(1, published.size()); + var event = published.get(0); + assertEquals(1, event.eventId()); + assertEquals(state, event.state()); + assertEquals(1, event.state().version()); + assertEquals("Lisbon", event.state().shippingAddress()); + assertEquals(new BigDecimal("500.00"), event.state().creditLimit()); + } + + @Test + void everyChangePublishesANewVersionWithTheWholeState() { + service.register("C-1", "Alice", "Lisbon", new BigDecimal("500.00")); + service.changeShippingAddress("C-1", "Porto"); + service.changeCreditLimit("C-1", new BigDecimal("900.00")); + + assertEquals( + List.of(1L, 2L, 3L), published.stream().map(CustomerUpdatedEvent::eventId).toList()); + var latest = published.get(2).state(); + assertEquals(3, latest.version()); + assertEquals("Porto", latest.shippingAddress()); + assertEquals(new BigDecimal("900.00"), latest.creditLimit()); + assertEquals("Alice", latest.name()); + } + + @Test + void rejectsChangesToUnknownCustomers() { + assertThrows(IllegalArgumentException.class, () -> service.changeShippingAddress("C-9", "x")); + assertThrows( + IllegalArgumentException.class, () -> service.changeCreditLimit("C-9", BigDecimal.TEN)); + assertTrue(published.isEmpty()); + } + + @Test + void answersDirectLookupsWhileOnline() { + service.register("C-1", "Alice", "Lisbon", new BigDecimal("500.00")); + + assertTrue(service.isOnline()); + assertEquals("Alice", service.findCustomer("C-1").orElseThrow().name()); + assertTrue(service.findCustomer("C-9").isEmpty()); + } + + @Test + void refusesDirectLookupsWhenOffline() { + service.register("C-1", "Alice", "Lisbon", new BigDecimal("500.00")); + service.shutdown(); + + assertFalse(service.isOnline()); + var thrown = assertThrows(IllegalStateException.class, () -> service.findCustomer("C-1")); + assertEquals("customer service is offline", thrown.getMessage()); + } +} diff --git a/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerStateTest.java b/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerStateTest.java new file mode 100644 index 000000000000..7e4773dd8ce2 --- /dev/null +++ b/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerStateTest.java @@ -0,0 +1,67 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; + +class CustomerStateTest { + + private final CustomerState initial = + new CustomerState("C-1", "Alice", "Lisbon", new BigDecimal("500.00"), 1); + + @Test + void changingTheAddressBumpsTheVersion() { + var moved = initial.withShippingAddress("Porto"); + + assertEquals("Porto", moved.shippingAddress()); + assertEquals(2, moved.version()); + assertEquals(initial.creditLimit(), moved.creditLimit()); + } + + @Test + void changingTheCreditLimitBumpsTheVersion() { + var richer = initial.withCreditLimit(new BigDecimal("900.00")); + + assertEquals(new BigDecimal("900.00"), richer.creditLimit()); + assertEquals(2, richer.version()); + assertEquals(initial.shippingAddress(), richer.shippingAddress()); + } + + @Test + void rejectsInvalidState() { + assertThrows( + IllegalArgumentException.class, + () -> new CustomerState("C-1", "Alice", "Lisbon", BigDecimal.ONE, 0)); + assertThrows( + NullPointerException.class, + () -> new CustomerState(null, "Alice", "Lisbon", BigDecimal.ONE, 1)); + assertThrows( + NullPointerException.class, () -> new CustomerState("C-1", "Alice", "Lisbon", null, 1)); + } +} diff --git a/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/EventBusTest.java b/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/EventBusTest.java new file mode 100644 index 000000000000..4d67198c6fae --- /dev/null +++ b/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/EventBusTest.java @@ -0,0 +1,75 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class EventBusTest { + + private final EventBus bus = new EventBus(); + + @Test + void deliversEventsToSubscribersOfTheirType() { + var received = new ArrayList(); + bus.subscribe(String.class, received::add); + + bus.publish("hello"); + bus.publish("world"); + + assertEquals(List.of("hello", "world"), received); + } + + @Test + void doesNotDeliverEventsOfOtherTypes() { + var received = new ArrayList(); + bus.subscribe(String.class, received::add); + + bus.publish(42); + + assertTrue(received.isEmpty()); + } + + @Test + void publishingWithoutSubscribersIsHarmless() { + assertDoesNotThrow(() -> bus.publish("nobody listens")); + } + + @Test + void deliversInSubscriptionOrder() { + var order = new ArrayList(); + bus.subscribe(String.class, event -> order.add("first:" + event)); + bus.subscribe(String.class, event -> order.add("second:" + event)); + + bus.publish("e"); + + assertEquals(List.of("first:e", "second:e"), order); + } +} diff --git a/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/OrderServiceTest.java b/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/OrderServiceTest.java new file mode 100644 index 000000000000..a53eaef0c170 --- /dev/null +++ b/event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/OrderServiceTest.java @@ -0,0 +1,110 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.eventcarriedstatetransfer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; + +class OrderServiceTest { + + private final EventBus bus = new EventBus(); + private final CustomerService customerService = new CustomerService(bus); + private final OrderService orderService = new OrderService(bus); + + @Test + void placesOrdersFromTheReplicatedState() { + customerService.register("C-1", "Alice", "Lisbon", new BigDecimal("500.00")); + + var order = orderService.placeOrder("C-1", new BigDecimal("120.00")); + + assertEquals("ORD-1", order.orderId()); + assertEquals("C-1", order.customerId()); + assertEquals("Lisbon", order.shippingAddress()); + assertEquals(new BigDecimal("120.00"), order.amount()); + } + + @Test + void usesTheLatestReplicatedAddress() { + customerService.register("C-1", "Alice", "Lisbon", new BigDecimal("500.00")); + customerService.changeShippingAddress("C-1", "Porto"); + + var order = orderService.placeOrder("C-1", new BigDecimal("10.00")); + + assertEquals("Porto", order.shippingAddress()); + } + + @Test + void keepsWorkingWhileTheCustomerServiceIsOffline() { + customerService.register("C-1", "Alice", "Lisbon", new BigDecimal("500.00")); + customerService.shutdown(); + + assertThrows(IllegalStateException.class, () -> customerService.findCustomer("C-1")); + var order = orderService.placeOrder("C-1", new BigDecimal("10.00")); + + assertEquals("Lisbon", order.shippingAddress()); + } + + @Test + void rejectsCustomersUnknownToTheReplica() { + var thrown = + assertThrows( + OrderRejectedException.class, () -> orderService.placeOrder("C-9", BigDecimal.ONE)); + + assertTrue(thrown.getMessage().contains("C-9")); + } + + @Test + void rejectsOrdersAboveTheReplicatedCreditLimit() { + customerService.register("C-1", "Alice", "Lisbon", new BigDecimal("500.00")); + + var thrown = + assertThrows( + OrderRejectedException.class, + () -> orderService.placeOrder("C-1", new BigDecimal("500.01"))); + + assertTrue(thrown.getMessage().contains("exceeds credit limit")); + } + + @Test + void acceptsOrdersExactlyAtTheCreditLimitAndNumbersThemSequentially() { + customerService.register("C-1", "Alice", "Lisbon", new BigDecimal("500.00")); + + orderService.placeOrder("C-1", new BigDecimal("1.00")); + var second = orderService.placeOrder("C-1", new BigDecimal("500.00")); + + assertEquals("ORD-2", second.orderId()); + } + + @Test + void exposesItsReplica() { + customerService.register("C-1", "Alice", "Lisbon", new BigDecimal("500.00")); + + assertEquals(1, orderService.replica().size()); + } +} diff --git a/pom.xml b/pom.xml index a71630d289d3..139cd5f92410 100644 --- a/pom.xml +++ b/pom.xml @@ -260,6 +260,7 @@ rate-limiting-pattern fallback onion-architecture + event-carried-state-transfer