feat: add Event-Carried State Transfer pattern (#2434) - #3602
Conversation
PR SummaryIntroduces the Event-Carried State Transfer (ECST) pattern as a new module. Produces full-state events, enables autonomous consumers with local replicas, and demonstrates producer offline scenarios. Includes in-memory bus, producer, replica, consumer, demo App, unit tests, UML diagram, and README with rationale and trade-offs. Changes
autogenerated by presubmit.ai |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #3602 +/- ##
============================================
+ Coverage 83.79% 83.97% +0.17%
- Complexity 4277 4323 +46
============================================
Files 1121 1130 +9
Lines 15144 15305 +161
Branches 723 729 +6
============================================
+ Hits 12690 12852 +162
+ Misses 2159 2158 -1
Partials 295 295 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
cabcc02 to
c92197c
Compare
|
Coverage follow-up: added |
There was a problem hiding this comment.
🚨 Pull request needs attention.
Review Summary
Files Processed (20)
- event-carried-state-transfer/README.md (1 hunk)
- event-carried-state-transfer/etc/event-carried-state-transfer.urm.puml (1 hunk)
- event-carried-state-transfer/pom.xml (1 hunk)
- event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/App.java (1 hunk)
- event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerReplica.java (1 hunk)
- event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerService.java (1 hunk)
- event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerState.java (1 hunk)
- event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerUpdatedEvent.java (1 hunk)
- event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/EventBus.java (1 hunk)
- event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/EventListener.java (1 hunk)
- event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/Order.java (1 hunk)
- event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/OrderRejectedException.java (1 hunk)
- event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/OrderService.java (1 hunk)
- event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/AppTest.java (1 hunk)
- event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerReplicaTest.java (1 hunk)
- event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerServiceTest.java (1 hunk)
- event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerStateTest.java (1 hunk)
- event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/EventBusTest.java (1 hunk)
- event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/OrderServiceTest.java (1 hunk)
- pom.xml (1 hunk)
Actionable Comments (5)
-
event-carried-state-transfer/pom.xml [1-70]
best_practice: "Add JUnit Jupiter API dependency"
-
event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/App.java [60-60]
best_practice: "Logger name mismatch with Lombok"
-
event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerService.java [1-145]
best_practice: "Logger name mismatch with Lombok"
-
event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/OrderService.java [49-51]
best_practice: "Logger name mismatch with Lombok"
-
event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/EventBus.java [56-56]
best_practice: "Logger name mismatch with Lombok"
Skipped Comments (1)
-
event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/EventBusTest.java [43-43]
readability: "Test listener compatibility with EventListener"
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <!-- | ||
|
|
||
| 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. | ||
|
|
||
| --> | ||
| <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | ||
| <modelVersion>4.0.0</modelVersion> | ||
| <parent> | ||
| <groupId>com.iluwatar</groupId> | ||
| <artifactId>java-design-patterns</artifactId> | ||
| <version>1.26.0-SNAPSHOT</version> | ||
| </parent> | ||
| <artifactId>event-carried-state-transfer</artifactId> | ||
| <dependencies> | ||
| <dependency> | ||
| <groupId>org.slf4j</groupId> | ||
| <artifactId>slf4j-api</artifactId> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>ch.qos.logback</groupId> | ||
| <artifactId>logback-classic</artifactId> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>org.junit.jupiter</groupId> | ||
| <artifactId>junit-jupiter-engine</artifactId> | ||
| <scope>test</scope> | ||
| </dependency> | ||
| </dependencies> | ||
| <build> | ||
| <plugins> | ||
| <plugin> | ||
| <groupId>org.apache.maven.plugins</groupId> | ||
| <artifactId>maven-assembly-plugin</artifactId> | ||
| <executions> | ||
| <execution> | ||
| <configuration> | ||
| <archive> | ||
| <manifest> | ||
| <mainClass>com.iluwatar.eventcarriedstatetransfer.App</mainClass> | ||
| </manifest> | ||
| </archive> | ||
| </configuration> | ||
| </execution> | ||
| </executions> | ||
| </plugin> | ||
| </plugins> | ||
| </build> | ||
| </project> |
There was a problem hiding this comment.
Missing JUnit 5 API dependency can cause test compilation failures when using the JUnit 5 engine. The engine alone may transitively pull in api in some setups, but this is not guaranteed. Add junit-jupiter-api as a test-scoped dependency to ensure tests compile in all environments.
| var customerService = new CustomerService(bus); | ||
| var orderService = new OrderService(bus); | ||
|
|
||
| LOGGER.info("--- Step 1: every customer change is published with the full customer state ---"); |
There was a problem hiding this comment.
Using a Lombok @Slf4j-generated logger named 'log' but the code references 'LOGGER'. This will fail to compile. Either switch all logging to 'log' or change the Lombok annotation to generate a 'LOGGER' field (not typical).
| /* | ||
| * 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. | ||
| * | ||
| * <p>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<String, CustomerState> 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<CustomerState> 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
Same Lombok logger mismatch as in App.java. All logging statements use 'LOGGER' but the class is annotated with @slf4j which provides a 'log' field. This will cause compilation errors. Update all occurrences to 'log' (or switch to a different Lombok annotation) and ensure consistency across modules.
| public OrderService(EventBus bus) { | ||
| bus.subscribe(CustomerUpdatedEvent.class, replica::apply); | ||
| } |
There was a problem hiding this comment.
Logger name mismatch in OrderService as well. All logging should use the Lombok-generated 'log' field to compile; otherwise switch to an explicit Logger field.
| var subscribers = listeners.computeIfAbsent(eventType, key -> new ArrayList<>()); | ||
| subscribers.add(listener); | ||
| LOGGER.info("Subscriber {} registered for {}", subscribers.size(), eventType.getSimpleName()); | ||
| } |
There was a problem hiding this comment.
EventBus uses Lombok @slf4j but logs with 'LOGGER'. This pattern repeats across the new classes and will cause compile errors. Align logging usage with the Lombok-generated logger.
What does this PR do?
Adds the Event-Carried State Transfer (ECST) pattern as a new
event-carried-state-transfermodule.CustomerUpdatedEventcarries the full customer state and a version. The consumer keeps its own replica, serves its use cases from that replica alone, never calls the producer back, and keeps working when the producer is offline.CustomerState(versioned, immutable record) andCustomerUpdatedEvent(carries the complete state).EventBus/EventListener: tiny synchronous in-memory publish/subscribe keyed by event type.CustomerService(producer): owns the authoritative state, publishes a full-state event on every change, can be taken offline to demonstrate consumer autonomy.CustomerReplica(consumer-side store): version-guarded upsert that ignores stale or duplicate events.OrderService(consumer): places orders using only the replica (unknown customer and credit limit checks, shipping address snapshot); it has no reference toCustomerService.App: four logged steps: replica follows each change, order placed while the producer is offline, stale event ignored, business rule enforced from the replica.README.md: intent, real-world example, Fowler quote, sequence diagram, code walkthrough with real program output, applicability, trade-offs (eventual consistency, duplicated data, versioning), and an explicit paragraph on how ECST differs from event notification, event sourcing and pub-sub. PlantUML class diagram underetc/.Appconstructor.pom.xml../mvnw clean verify -pl event-carried-state-transferpasses locally on JDK 21 and inside aneclipse-temurin:21container; the packaged jar runs end to end.Fixes #2434