Skip to content

feat: add Event-Carried State Transfer pattern (#2434) - #3602

Open
ylcn91 wants to merge 1 commit into
iluwatar:masterfrom
ylcn91:feat/event-carried-state-transfer
Open

feat: add Event-Carried State Transfer pattern (#2434)#3602
ylcn91 wants to merge 1 commit into
iluwatar:masterfrom
ylcn91:feat/event-carried-state-transfer

Conversation

@ylcn91

@ylcn91 ylcn91 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds the Event-Carried State Transfer (ECST) pattern as a new event-carried-state-transfer module.

  • Problem: with plain event notifications a consumer learns only that something changed and has to call the producer back for the details, which couples the two services and makes the consumer unavailable whenever the producer is.
  • Solution: every CustomerUpdatedEvent carries 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.
  • Key components:
    • CustomerState (versioned, immutable record) and CustomerUpdatedEvent (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 to CustomerService.
    • 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 under etc/.
  • Tests: 27 JUnit 5 tests (bus, state, producer, replica, consumer, App); the only uncovered line is the implicit App constructor.
  • Module registered in the parent pom.xml. ./mvnw clean verify -pl event-carried-state-transfer passes locally on JDK 21 and inside an eclipse-temurin:21 container; the packaged jar runs end to end.

Fixes #2434

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

PR Summary

Introduces 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

File Summary
event-carried-state-transfer/README.md Documentation describing the ECST pattern, its rationale, a real-world example, a sequence diagram, and a runnable Java walkthrough, including a PlantUML diagram.
event-carried-state-transfer/etc/event-carried-state-transfer.urm.puml PlantUML class diagram for the ECST module, illustrating core classes (CustomerState, CustomerUpdatedEvent, EventBus, CustomerService, CustomerReplica, OrderService, App) and their relationships.
event-carried-state-transfer/pom.xml Module POM declaring dependencies and build setup for the ECST module, including main class and test dependencies.
event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/App.java Demo application wiring the EventBus, ECST producer, and replica-based consumer to demonstrate the lifecycle: full-state events, offline producer operation, stale event handling, and business rules from the replica.
event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerReplica.java In-memory replica with version-guarded upsert; stores CustomerState per customer and supports lookup and size operations; logs applied events.
event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerService.java Producer that registers customers, publishes CustomerUpdatedEvent carrying the full state on each change, and supports an offline mode for demonstrating consumer autonomy.
event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerState.java Immutable CustomerState with validation in constructor; provides copy methods to produce updated versions on changes.
event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/CustomerUpdatedEvent.java Event carrying the complete CustomerState, along with event metadata (id and timestamp).
event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/EventBus.java Minimal in-memory, synchronous publish/subscribe channel with subscribe and publish capabilities.
event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/EventListener.java Functional interface for event listeners to receive events from the EventBus.
event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/Order.java Order value object containing orderId, customerId, shippingAddress, and amount.
event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/OrderRejectedException.java Runtime exception signaling an order rejection due to replica rules.
event-carried-state-transfer/src/main/java/com/iluwatar/eventcarriedstatetransfer/OrderService.java Consumer-side service that subscribes to CustomerUpdatedEvent and serves orders from the replica; exposes the replica for inspection.
event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/AppTest.java JUnit tests for App: instantiation, main execution, direct lookup, and order placement helpers.
event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerReplicaTest.java Tests for applying events, version-based upsert behavior, and handling of late or duplicate deliveries.
event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerServiceTest.java Tests for registration, event publication, and direct lookups online/offline, including error scenarios.
event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/CustomerStateTest.java Tests for state transitions and validation rules (address and creditLimit updates).
event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/EventBusTest.java Tests for EventBus behavior: per-type delivery, ordering, and safe publishing with no subscribers.
event-carried-state-transfer/src/test/java/com/iluwatar/eventcarriedstatetransfer/OrderServiceTest.java Tests for placing orders from the replica, including address updates, offline operation, and rejection cases.
pom.xml Root POM updated to include the new ECST module in the build modules.

autogenerated by presubmit.ai

@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.97%. Comparing base (41625d8) to head (c92197c).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ylcn91
ylcn91 force-pushed the feat/event-carried-state-transfer branch from cabcc02 to c92197c Compare September 3, 2026 11:32
@ylcn91

ylcn91 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Coverage follow-up: added AppTest.shouldBeInstantiable for the implicit App constructor, the last uncovered line. JaCoCo now reports 100% instruction, branch and line coverage. Verified locally on JDK 21 and in an eclipse-temurin:21 container.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 Pull request needs attention.

Review Summary

Commits Considered (1)
  • c92197c: feat: add Event-Carried State Transfer pattern (#2434)
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"

Comment on lines +1 to +70
<?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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ---");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines +1 to +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.
*
* <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;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +49 to +51
public OrderService(EventBus bus) {
bus.subscribe(CustomerUpdatedEvent.class, replica::apply);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Event-carried state transfer pattern

1 participant