Add order-fulfillment module: Spring Modulith 2.1 boundary enforcement, events, generated docs

This commit is contained in:
2026-10-03 18:45:37 +00:00
commit 866eceed0a
34 changed files with 1149 additions and 0 deletions
@@ -0,0 +1,23 @@
package com.ankurm.modulithdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.modulith.Modulithic;
/**
* Entry point for the order-fulfillment demo.
*
* <p>Four application modules live as direct sub-packages of this class's package:
* {@code order}, {@code inventory}, {@code shipping} and {@code notification}. Spring
* Modulith treats each one as a module automatically (package-by-feature convention) —
* see {@link com.ankurm.modulithdemo.ModularityTests} for the boundary verification and
* documentation-generation tests that prove it.
*/
@SpringBootApplication
@Modulithic(systemName = "Order Fulfillment")
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@@ -0,0 +1,50 @@
package com.ankurm.modulithdemo.inventory;
import com.ankurm.modulithdemo.inventory.internal.Stock;
import com.ankurm.modulithdemo.inventory.internal.StockRepository;
import com.ankurm.modulithdemo.order.OrderPlaced;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Service;
/**
* The {@code inventory} module's public API. {@link #isInStock(String, int)} is the
* sanctioned way for another module to ask about stock levels — it is the method the
* "fixed" version of {@code OrderManagement} calls instead of reaching into
* {@link com.ankurm.modulithdemo.inventory.internal.StockRepository} directly.
*/
@Service
public class InventoryManagement {
private final StockRepository stock;
private final ApplicationEventPublisher events;
public InventoryManagement(StockRepository stock, ApplicationEventPublisher events) {
this.stock = stock;
this.events = events;
}
public boolean isInStock(String sku, int quantity) {
return stock.findBySku(sku).map(s -> s.getAvailable() >= quantity).orElse(false);
}
/**
* Reacts to an order being placed, in its own transaction, asynchronously — this is
* what {@code @ApplicationModuleListener} buys over a plain
* {@code @EventListener}: {@code OrderManagement.placeOrder()} already returned
* before this method runs, and a failure here cannot roll back the order.
*
* <p>Note there is no explicit {@code @Transactional} here — {@code
* @ApplicationModuleListener} already carries {@code @Transactional(propagation =
* REQUIRES_NEW)} as a meta-annotation. Adding a second one on top doesn't layer, it
* breaks bean registration outright; see {@code output/05-redundant-transactional-failure.txt}.
*/
@ApplicationModuleListener
public void on(OrderPlaced event) {
var item = stock.findBySku(event.sku()).orElseThrow();
item.reserve(event.quantity());
stock.save(item);
events.publishEvent(new StockReserved(event.orderId(), event.sku(), item.getAvailable()));
}
}
@@ -0,0 +1,12 @@
package com.ankurm.modulithdemo.inventory;
/**
* Published once stock has been decremented for an order. {@code shipping} and
* {@code notification} both react to this.
*
* @param orderId the order the stock was reserved for
* @param sku the product reserved
* @param remaining units left in stock after this reservation
*/
public record StockReserved(Long orderId, String sku, int remaining) {
}
@@ -0,0 +1,41 @@
package com.ankurm.modulithdemo.inventory.internal;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String sku;
private int available;
protected Stock() {
}
public Stock(String sku, int available) {
this.sku = sku;
this.available = available;
}
public Long getId() {
return id;
}
public String getSku() {
return sku;
}
public int getAvailable() {
return available;
}
public void reserve(int quantity) {
this.available -= quantity;
}
}
@@ -0,0 +1,15 @@
package com.ankurm.modulithdemo.inventory.internal;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* The repository behind {@code inventory}'s stock table. This is exactly the kind of type
* Spring Modulith expects to stay inside its own module — see
* {@link com.ankurm.modulithdemo.order.OrderManagement} in the "boundary violation" branch
* of the post for what happens when another module imports it anyway.
*/
public interface StockRepository extends JpaRepository<Stock, Long> {
Optional<Stock> findBySku(String sku);
}
@@ -0,0 +1,26 @@
package com.ankurm.modulithdemo.inventory.internal;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
/**
* Seeds a starting stock level so the demo has something to reserve against. Internal —
* nothing outside {@code inventory} should ever need this.
*/
@Component
class StockSeeder implements ApplicationRunner {
private final StockRepository stock;
StockSeeder(StockRepository stock) {
this.stock = stock;
}
@Override
public void run(ApplicationArguments args) {
if (stock.findBySku("WIDGET-1").isEmpty()) {
stock.save(new Stock("WIDGET-1", 100));
}
}
}
@@ -0,0 +1,42 @@
package com.ankurm.modulithdemo.notification;
import com.ankurm.modulithdemo.notification.internal.Notification;
import com.ankurm.modulithdemo.notification.internal.NotificationRepository;
import com.ankurm.modulithdemo.order.OrderPlaced;
import com.ankurm.modulithdemo.shipping.ShipmentScheduled;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Service;
/**
* The {@code notification} module's public API. It depends on two other modules' events
* — {@link OrderPlaced} from {@code order} and {@link ShipmentScheduled} from
* {@code shipping} — but never on either module's service bean or persistence layer. Two
* separate {@code @ApplicationModuleListener} methods, two separate hops in the chain.
*/
@Service
public class NotificationManagement {
private static final Logger log = LoggerFactory.getLogger(NotificationManagement.class);
private final NotificationRepository notifications;
public NotificationManagement(NotificationRepository notifications) {
this.notifications = notifications;
}
@ApplicationModuleListener
public void on(OrderPlaced event) {
var message = "Order " + event.orderId() + " received for " + event.quantity() + "x " + event.sku();
log.info(message);
notifications.save(new Notification(event.orderId(), message));
}
@ApplicationModuleListener
public void on(ShipmentScheduled event) {
var message = "Order " + event.orderId() + " is on its way (" + event.sku() + ")";
log.info(message);
notifications.save(new Notification(event.orderId(), message));
}
}
@@ -0,0 +1,37 @@
package com.ankurm.modulithdemo.notification.internal;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long orderId;
private String message;
protected Notification() {
}
public Notification(Long orderId, String message) {
this.orderId = orderId;
this.message = message;
}
public Long getId() {
return id;
}
public Long getOrderId() {
return orderId;
}
public String getMessage() {
return message;
}
}
@@ -0,0 +1,9 @@
package com.ankurm.modulithdemo.notification.internal;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface NotificationRepository extends JpaRepository<Notification, Long> {
List<Notification> findByOrderId(Long orderId);
}
@@ -0,0 +1,25 @@
package com.ankurm.modulithdemo.order;
import com.ankurm.modulithdemo.order.internal.Order;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderManagement orders;
public OrderController(OrderManagement orders) {
this.orders = orders;
}
@PostMapping
public Order place(@RequestParam String sku, @RequestParam int quantity) {
return orders.placeOrder(sku, quantity);
}
@GetMapping("/{id}")
public Order get(@PathVariable Long id) {
return orders.get(id);
}
}
@@ -0,0 +1,63 @@
package com.ankurm.modulithdemo.order;
import com.ankurm.modulithdemo.order.internal.Order;
import com.ankurm.modulithdemo.order.internal.OrderRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* The {@code order} module's public API. Everything another module is allowed to call
* about orders goes through this class and the events it publishes — see
* {@link OrderPlaced}.
*
* <p><b>Why there is no call into {@code inventory} here, not even through its public
* API.</b> An earlier version of this class reached straight into
* {@code inventory.internal.StockRepository} to check stock before placing an order.
* {@code ApplicationModules.verify()} failed that with two violations: a dependency
* cycle, and a reference to a non-exposed type (see {@code
* output/01-verify-violation-failed.txt}). The instinctive fix — route the same call
* through {@link com.ankurm.modulithdemo.inventory.InventoryManagement}, inventory's
* actual public API — turns out to still fail with "Cycle detected" (see {@code
* output/03-cycle-via-public-api-still-failed.txt}), because {@code inventory} already
* depends on {@code order}: its {@code @ApplicationModuleListener} takes
* {@link OrderPlaced} as a parameter, which is a dependency edge regardless of whether
* the listening module calls anything synchronously. Going through the public API only
* fixes the "non-exposed type" violation; it cannot fix a cycle, because the cycle isn't
* about which types are public — it's about the direction already being spoken for.
* <p>
* The actual fix is to not add the second edge: {@code order} publishes {@link
* OrderPlaced} and stops caring what happens next. If inventory can't satisfy the order,
* that's inventory's problem to signal back — as a new event {@code order} chooses to
* listen for, which is a deliberate second module relationship, not a shortcut around
* the first one. (Sorting out that back-channel without re-introducing a cycle is exactly
* what the Saga pattern post in this series is for.)
*/
@Service
public class OrderManagement {
private final OrderRepository orders;
private final ApplicationEventPublisher events;
public OrderManagement(OrderRepository orders, ApplicationEventPublisher events) {
this.orders = orders;
this.events = events;
}
@Transactional
public Order placeOrder(String sku, int quantity) {
var order = orders.save(new Order(sku, quantity, "PLACED"));
// Published in the same transaction as the insert above. Spring Modulith's event
// publication registry logs one row per @ApplicationModuleListener before this
// method returns, so the event can never be lost even if the process dies the
// instant after commit — see the "What the defaults do not do" section of the post.
events.publishEvent(new OrderPlaced(order.getId(), order.getSku(), order.getQuantity()));
return order;
}
public Order get(Long orderId) {
return orders.findById(orderId).orElseThrow();
}
}
@@ -0,0 +1,13 @@
package com.ankurm.modulithdemo.order;
/**
* Published once an order has been persisted. This is the {@code order} module's public
* API surface for the event — every field here is a value the receiving module is allowed
* to depend on, nothing more.
*
* @param orderId the persisted order's id
* @param sku the product being ordered
* @param quantity how many units
*/
public record OrderPlaced(Long orderId, String sku, int quantity) {
}
@@ -0,0 +1,61 @@
package com.ankurm.modulithdemo.order.internal;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/**
* The order module's internal JPA entity. Nothing outside the {@code order} package is
* meant to see this class — other modules only ever see the {@code orderId} and
* {@code sku} carried on {@link com.ankurm.modulithdemo.order.OrderPlaced}.
*
* <p><b>{@code @Table(name = "orders")} is not decoration.</b> {@code ORDER} is a
* reserved SQL keyword (it's the {@code ORDER BY} clause) and H2 — like most databases —
* rejects {@code CREATE TABLE order (...)} outright. Hibernate will happily default the
* table name to the entity's simple name, so this is caught the first time schema
* generation runs, not at compile time. See {@code output/00-table-name-collision.txt}.
*/
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String sku;
private int quantity;
private String status;
protected Order() {
// for JPA
}
public Order(String sku, int quantity, String status) {
this.sku = sku;
this.quantity = quantity;
this.status = status;
}
public Long getId() {
return id;
}
public String getSku() {
return sku;
}
public int getQuantity() {
return quantity;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}
@@ -0,0 +1,13 @@
package com.ankurm.modulithdemo.order.internal;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* Internal persistence port for the {@code order} module. Package-private visibility is
* not required for Spring Modulith's default module model — everything under
* {@code order.internal} is internal by convention, public or not — but this interface
* is deliberately public anyway, because the whole point of {@link OrderManagement} is to
* show what happens when something outside this package reaches for it directly.
*/
public interface OrderRepository extends JpaRepository<Order, Long> {
}
@@ -0,0 +1,10 @@
package com.ankurm.modulithdemo.shipping;
/**
* Published once a shipment has been scheduled for a reserved order.
*
* @param orderId the order being shipped
* @param sku the product shipped
*/
public record ShipmentScheduled(Long orderId, String sku) {
}
@@ -0,0 +1,32 @@
package com.ankurm.modulithdemo.shipping;
import com.ankurm.modulithdemo.inventory.StockReserved;
import com.ankurm.modulithdemo.shipping.internal.Shipment;
import com.ankurm.modulithdemo.shipping.internal.ShipmentRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Service;
/**
* The {@code shipping} module's public API. Reacts to {@link StockReserved} published by
* {@code inventory} — this is the second hop in the chain, proof that module events chain
* across more than one listener without any module calling another's service bean
* directly.
*/
@Service
public class ShippingManagement {
private final ShipmentRepository shipments;
private final ApplicationEventPublisher events;
public ShippingManagement(ShipmentRepository shipments, ApplicationEventPublisher events) {
this.shipments = shipments;
this.events = events;
}
@ApplicationModuleListener
public void on(StockReserved event) {
shipments.save(new Shipment(event.orderId(), event.sku(), "SCHEDULED"));
events.publishEvent(new ShipmentScheduled(event.orderId(), event.sku()));
}
}
@@ -0,0 +1,43 @@
package com.ankurm.modulithdemo.shipping.internal;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Shipment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long orderId;
private String sku;
private String status;
protected Shipment() {
}
public Shipment(Long orderId, String sku, String status) {
this.orderId = orderId;
this.sku = sku;
this.status = status;
}
public Long getId() {
return id;
}
public Long getOrderId() {
return orderId;
}
public String getSku() {
return sku;
}
public String getStatus() {
return status;
}
}
@@ -0,0 +1,6 @@
package com.ankurm.modulithdemo.shipping.internal;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ShipmentRepository extends JpaRepository<Shipment, Long> {
}
@@ -0,0 +1,27 @@
spring.application.name=order-fulfillment
spring.datasource.url=jdbc:h2:mem:modulith;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
# Spring Modulith's JDBC/JPA event publication registry creates its own table
# on top of whatever schema-generation strategy is already in play.
spring.modulith.events.jdbc.schema-initialization.enabled=true
# Republish anything left PUBLISHED/PROCESSING by an unclean shutdown.
spring.modulith.events.republish-outstanding-events-on-restart=true
# NOT enabled for this demo (left commented so the defaults above are what actually ran
# for every captured transcript in output/). In production, turn these on:
# spring.modulith.events.staleness.published=PT5M
# spring.modulith.events.staleness.processing=PT5M
# spring.modulith.events.staleness.resubmitted=PT5M
# spring.modulith.events.completion-mode=ARCHIVE
# Without them: completed rows accumulate forever (completion-mode defaults to UPDATE,
# which never deletes), and a row stuck in PUBLISHED/PROCESSING after a crash has no
# staleness monitor to mark it FAILED and eligible for resubmission -- all three
# staleness durations default to zero, which this library treats as "off".
logging.level.com.ankurm.modulithdemo=INFO
logging.level.org.springframework.modulith=INFO
@@ -0,0 +1,39 @@
package com.ankurm.modulithdemo;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;
/**
* The two tests every Spring Modulith project ends up with:
* <p>
* 1. {@link #verifiesModuleStructure()} — fails the build the moment any module reaches
* across a boundary it shouldn't. This test is what turned RED when
* {@code OrderManagement} imported {@code inventory.internal.StockRepository}
* directly (captured in {@code output/01-verify-violation-failed.txt}) and GREEN again
* once that was replaced with a call through {@code InventoryManagement} (captured in
* {@code output/02-verify-passed.txt}).
* <p>
* 2. {@link #writesDocumentation()} — generates a PlantUML component diagram, one diagram
* per module, and a canvas (beans / events / properties) for each module, straight from
* the bytecode. Output lands in {@code target/spring-modulith-docs}; a copy of what it
* produced for this project is captured in {@code output/03-generated-docs-listing.txt}.
*/
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(Application.class);
@Test
void verifiesModuleStructure() {
modules.verify();
}
@Test
void writesDocumentation() {
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml()
.writeModuleCanvases()
.writeAggregatingDocument();
}
}
@@ -0,0 +1,46 @@
package com.ankurm.modulithdemo.order;
import com.ankurm.modulithdemo.inventory.StockReserved;
import com.ankurm.modulithdemo.shipping.ShipmentScheduled;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.Scenario;
import java.time.Duration;
/**
* Runs the whole chain for real: publishing {@link OrderPlaced} should make
* {@code inventory} reserve stock and publish {@link StockReserved}, which should make
* {@code shipping} schedule a shipment and publish {@link ShipmentScheduled} — three
* modules, two hops, no module ever calling another module's service bean directly.
*
* <p><b>{@code mode = ALL_DEPENDENCIES} alone is not enough here</b> — and the first
* version of this test proved it, timing out after 10 seconds waiting for an event that
* never arrived (captured in {@code output/04-event-flow-missing-dependency.txt}).
* {@code ALL_DEPENDENCIES} bootstraps {@code order} plus the modules {@code order}
* <i>imports from</i>. But {@code order} doesn't import anything from {@code inventory},
* {@code shipping} or {@code notification} — the dependency runs the other way: those
* modules import {@code order}'s event types and listen for them. So from {@code order}'s
* point of view it has, correctly, zero module dependencies, and
* {@code InventoryManagement}, {@code ShippingManagement} and
* {@code NotificationManagement} are never instantiated — the event is published into a
* context with no listeners, and simply vanishes. {@code extraIncludes} pulls them in
* explicitly.
*/
@ApplicationModuleTest(
mode = ApplicationModuleTest.BootstrapMode.ALL_DEPENDENCIES,
extraIncludes = { "inventory", "shipping", "notification" })
class OrderFulfillmentIntegrationTests {
@Test
void placingAnOrderCascadesThroughInventoryAndShipping(Scenario scenario) {
scenario.publish(new OrderPlaced(1L, "WIDGET-1", 3))
.andWaitForEventOfType(ShipmentScheduled.class)
.matching(event -> event.orderId().equals(1L))
.toArriveAndVerify(event -> {
if (!event.sku().equals("WIDGET-1")) {
throw new AssertionError("expected WIDGET-1, got " + event.sku());
}
});
}
}