Add order-fulfillment module: Spring Modulith 2.1 boundary enforcement, events, generated docs
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
+26
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -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));
|
||||
}
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
}
|
||||
+9
@@ -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;
|
||||
}
|
||||
}
|
||||
+13
@@ -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> {
|
||||
}
|
||||
+10
@@ -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) {
|
||||
}
|
||||
+32
@@ -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()));
|
||||
}
|
||||
}
|
||||
+43
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
@@ -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();
|
||||
}
|
||||
}
|
||||
+46
@@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user