Skip to main content

Spring Modulith 2.1: Enforcing Module Boundaries Inside a Spring Boot Monolith

A clean package diagram is a promise, not an enforcement mechanism. This post builds a real 4-module Spring Boot order-fulfillment app, breaks the boundary between two modules on purpose, watches ApplicationModules.verify() fail with a real stack trace, and shows why the fix most people reach for first — routing through the public API instead of the internals — still fails, and why that matters more than the original mistake.

Every modular monolith starts the same way: a clean diagram, a handful of packages named after the business, and a promise that order will not reach into inventory’s business. Six months and four developers later, somebody adds exactly that reach — a repository field, a quick read, “it’s all one database anyway” — and nothing stops them. The code compiles. The tests that exist still pass. The diagram is now a lie, and nobody finds out until the day someone tries to split the monolith and discovers the boundaries were never real.

Spring Modulith is Spring’s answer to that specific failure: not a framework for writing microservices-shaped code inside one deployable, but a verification tool that turns your package structure into a contract a test can enforce. This post builds a small, real order-fulfillment app with four modules, breaks the boundary between two of them on purpose, watches the actual test failure, fixes it, and then shows that the “fix” most people reach for first — routing the call through the public API instead of the internals — still fails, for a reason that matters more than the original mistake.

Versions used in this post, verified against Maven Central’s maven-metadata.xml, not the project’s own <release> tag (which currently points at the 2.2.0-M2 milestone — a GA pointer that has been wrong before is not a GA pointer you can trust). Spring Boot 4.1.1 · Spring Modulith 2.1.1 (2.1 went GA 11 June 2026; 2.1.1 is the current patch; 2.2 exists only as milestones as of this writing) · JDK 25 LTS · H2 2.4.240 for the demo database.

What a “module” is to Spring Modulith

Spring Modulith does not introduce a new way to write Spring code. It looks at packages you were probably going to create anyway and gives them meaning. The default convention — and the one this post uses — is package by feature: every direct sub-package of the package holding @SpringBootApplication is a module. No XML, no registry, no annotation required to declare one.

The demo app has four: order, inventory, shipping, and notification, each a sub-package of com.ankurm.modulithdemo. Inside each module, anything directly in the module’s own package is that module’s public API — fair game for other modules to depend on. Anything one level deeper, in an internal sub-package, is exactly that: internal, and off-limits to everyone else, regardless of whether the Java compiler marks it public. Java’s own visibility modifiers stop at the package; Spring Modulith is the thing that enforces the rest.

Module dependency graph (as generated by Documenter) order inventory shipping notification listens to OrderPlaced listens to StockReserved listens to OrderPlaced listens to ShipmentScheduled order has zero outgoing arrows. Nothing it does depends on inventory, shipping, or notification — by design.

That diagram is not something I drew by hand and hoped matches the code. It is the real output of Spring Modulith’s own Documenter, reading this repository’s bytecode and generating a PlantUML component diagram and a per-module canvas — see the accordion near the end of this post for the raw PlantUML and the full captured transcript. Every arrow is a "listens to" relationship the tool found by inspecting @ApplicationModuleListener method parameters, not something I asserted.

Notice the direction. inventory and notification both point at order, and notification also points at shipping. order points at nothing. That is not an accident of this particular app; it is the whole design goal, and it is the thing that gets broken in the next section.

The test that makes the diagram enforceable

The diagram above is only worth anything if something fails the build the moment it stops being true. That something is two lines:

class ModularityTests {

    ApplicationModules modules = ApplicationModules.of(Application.class);

    @Test
    void verifiesModuleStructure() {
        modules.verify();
    }
}

ModularityTests.java

ApplicationModules.of(Application.class) scans the bytecode reachable from the application’s main package and builds the module model described above. verify() walks every type dependency it finds — constructor parameters, fields, method bodies, the lot — and throws org.springframework.modulith.core.Violations, a RuntimeException, the moment one of them crosses a module boundary it should not. Put it in a JUnit test and it becomes a build-breaking architecture check that runs on every commit, with zero ArchUnit rules to hand-write.

The app itself needs nothing special to make this work. Here is the whole bootstrap class:

@SpringBootApplication
@Modulithic(systemName = "Order Fulfillment")
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Application.java — @Modulithic is optional (it names the system for the generated documentation) but ApplicationModules.of() works with a plain @SpringBootApplication class just as well.

The Maven coordinates for all of this are one BOM import and two starters — one for the main app, one for the tests:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.modulith</groupId>
      <artifactId>spring-modulith-bom</artifactId>
      <version>2.1.1</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

pom.xml

Breaking it on purpose

Here is the shortcut a real developer takes under real time pressure. Before placing an order, OrderManagement wants to know whether there is enough stock. The honest way is to ask inventory’s public API. The fast way, if you already know the schema and you are in a hurry, is to inject inventory.internal.StockRepository directly and query it yourself:

public class OrderManagement {

    private final OrderRepository orders;
    private final ApplicationEventPublisher events;

    // the shortcut: read inventory's table directly instead of asking inventory
    private final StockRepository stockShortcut;

    public OrderManagement(OrderRepository orders,
                            ApplicationEventPublisher events,
                            StockRepository stockShortcut) {
        this.orders = orders;
        this.events = events;
        this.stockShortcut = stockShortcut;
    }

    @Transactional
    public Order placeOrder(String sku, int quantity) {
        stockShortcut.findBySku(sku);   // "it's all one database anyway"
        var order = orders.save(new Order(sku, quantity, "PLACED"));
        events.publishEvent(new OrderPlaced(order.getId(), order.getSku(), order.getQuantity()));
        return order;
    }
}

This exact version was never committed — it exists only to fail the test below. The real git history goes straight from the original OrderManagement.java to the fixed one; the failure it produced is captured verbatim next.

This compiles. It runs. If you call the endpoint by hand, you will not see anything wrong. The only thing standing between this code and production is the test from the previous section — and here is exactly what it says when it runs against this version of the code:

org.springframework.modulith.core.Violations:
- Cycle detected: Slice inventory ->
                Slice order ->
                Slice inventory
  2. Dependencies of Slice order
    - Field <com.ankurm.modulithdemo.order.OrderManagement.stockShortcut> has type
      <com.ankurm.modulithdemo.inventory.internal.StockRepository> in (OrderManagement.java:0)
    - Method <com.ankurm.modulithdemo.order.OrderManagement.placeOrder(java.lang.String, int)>
      calls method <com.ankurm.modulithdemo.inventory.internal.StockRepository.findBySku(java.lang.String)>
      in (OrderManagement.java:39)
- Module 'order' depends on non-exposed type
  com.ankurm.modulithdemo.inventory.internal.StockRepository within module 'inventory'!

[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0

Full transcript: output/01-verify-violation-failed.txt

One field produced two separate violations in the same run, and they are worth telling apart, because they have different fixes.

The second one — "depends on non-exposed type" — is the one most people expect: StockRepository lives under inventory.internal, and nothing outside inventory is allowed to reference it, public interface or not.

The first one — "Cycle detected: Slice inventory -> Slice order -> Slice inventory" — is the one that catches people out, and it is the subject of the rest of this section. inventory already depends on order: its @ApplicationModuleListener method takes OrderPlaced as a parameter, and that is a real dependency edge as far as Spring Modulith is concerned, identical in kind to a constructor argument. Adding a field that points the other way — order depending on anything in inventory — closes a two-module cycle, and Spring Modulith treats the module graph as a strict DAG by default. A cycle fails verification even before the tool asks whether the specific type you reached for happens to be internal.

The instinct to fix this by “going through the public API” is natural, and it is wrong here. Watch what happens next.

The obvious-looking fix is to leave the field, but point it at InventoryManagement — inventory’s real, intentional public API — instead of the internal repository:

public class OrderManagement {

    private final OrderRepository orders;
    private final ApplicationEventPublisher events;
    private final InventoryManagement inventory;   // the real public API this time

    public OrderManagement(OrderRepository orders,
                            ApplicationEventPublisher events,
                            InventoryManagement inventory) {
        this.orders = orders;
        this.events = events;
        this.inventory = inventory;
    }

    @Transactional
    public Order placeOrder(String sku, int quantity) {
        if (!inventory.isInStock(sku, quantity)) {
            throw new IllegalStateException("Not enough stock for " + sku);
        }
        var order = orders.save(new Order(sku, quantity, "PLACED"));
        events.publishEvent(new OrderPlaced(order.getId(), order.getSku(), order.getQuantity()));
        return order;
    }
}

Also never committed, for the same reason — see InventoryManagement.java for the real, public isInStock(...) method this version calls.

Run the same test against this version:

org.springframework.modulith.core.Violations: - Cycle detected: Slice inventory ->
                Slice order ->
                Slice inventory
  2. Dependencies of Slice order
    - Field <com.ankurm.modulithdemo.order.OrderManagement.inventory> has type
      <com.ankurm.modulithdemo.inventory.InventoryManagement> in (OrderManagement.java:0)
    - Method <com.ankurm.modulithdemo.order.OrderManagement.placeOrder(java.lang.String, int)>
      calls method <com.ankurm.modulithdemo.inventory.InventoryManagement.isInStock(java.lang.String, int)>
      in (OrderManagement.java:40)

Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 -- BUILD FAILURE

Notice: no "non-exposed type" complaint this time -- InventoryManagement is inventory's
real public API, exactly as advertised. Only "Cycle detected" remains.

Full transcript: output/03-cycle-via-public-api-still-failed.txt

The non-exposed-type complaint is gone, exactly as expected — InventoryManagement is a perfectly legitimate public type. The cycle is still there, because the cycle was never about which types are public. It is about direction, and the direction was already spoken for the moment inventory chose to listen for OrderPlaced.

Closing the loop: order ↔ inventory order inventory listens to OrderPlaced (already there) calls InventoryManagement.isInStock(…) (the “fix”) Routing the second arrow through InventoryManagement — a real public type — removes the “non-exposed type” violation but not the cycle. verify() still fails: two arrows, two directions, one pair of modules.

The only fix that actually works is to not add the second edge. order publishes OrderPlaced and stops caring what happens to it:

@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"));
        events.publishEvent(new OrderPlaced(order.getId(), order.getSku(), order.getQuantity()));
        return order;
    }
}

OrderManagement.java (final version)

[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.748 s -- in com.ankurm.modulithdemo.order.OrderFulfillmentIntegrationTests
[INFO] Running com.ankurm.modulithdemo.ModularityTests
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.471 s -- in com.ankurm.modulithdemo.ModularityTests
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Same ApplicationModules.verify() call, same four modules, zero violations. The only
change from output/01-verify-violation-failed.txt is that OrderManagement no longer
depends on anything in inventory, in either direction.

Full transcript: output/02-verify-passed.txt

If inventory genuinely cannot satisfy an order, that has to travel back as a new event order deliberately chooses to listen for — a second, intentional relationship, not a shortcut around the first one. Keeping that back-channel from becoming a cycle of its own is a big enough topic that it gets its own post later in this series, on the Saga pattern.

Going deeper: NamedInterface and open modules — when a module needs to expose more than its base package

Sometimes a module genuinely needs to expose more than flat types in its base package — an SPI sub-package meant for one or two trusted callers, say. Spring Modulith has two escape hatches, both narrower than they look:

@NamedInterface("spi") on a package-info.java inside inventory.spi promotes that one sub-package to additional public API, referenced from other modules’ @ApplicationModule(allowedDependencies = "inventory :: spi") declarations. It is scoped and explicit — exactly one extra package, named in the dependency declaration of whoever is allowed to use it.

@ApplicationModule(type = Type.OPEN) on the module’s package-info.java is the blunt version: every internal type in that module becomes visible to everyone. The reference documentation is direct about this not being recommended for a well-modularized application, and after watching verify() catch the StockRepository shortcut above, it should be obvious why — OPEN does not fix the design problem, it just turns off the thing that was telling you about it.

Neither of these would have saved the order → inventory shortcut in this post. They widen who can see a module’s internals; they do nothing about a cycle, which is a statement about direction, not visibility.

How the cascade actually runs underneath

Once order publishes OrderPlaced and gets out of the way, three independent listeners pick it up. Here is inventory’s:

@Service
public class InventoryManagement {

    private final StockRepository stock;
    private final ApplicationEventPublisher events;

    public boolean isInStock(String sku, int quantity) {
        return stock.findBySku(sku).map(s -> s.getAvailable() >= quantity).orElse(false);
    }

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

InventoryManagement.java

@ApplicationModuleListener looks like a plain marker annotation. It is not. Reading the actual compiled class file — not the reference docs, the bytecode — shows exactly what it expands to:

$ javap -v org/springframework/modulith/events/ApplicationModuleListener.class

RuntimeVisibleAnnotations:
  0: Async
  1: Transactional(propagation=Propagation.REQUIRES_NEW)
  2: TransactionalEventListener

Full transcript: output/08-javap-listener-meta-annotations.txt

So @ApplicationModuleListener is @Async + @Transactional(propagation = REQUIRES_NEW) + @TransactionalEventListener, stacked as meta-annotations on one declaration. That combination is the whole mechanism:

  • @TransactionalEventListener, default phase AFTER_COMMIT: the listener only runs once the transaction that published the event has actually committed. Publish inside a transaction that rolls back, and the listener never fires — which is correct, and also means publishing an event from code with no active transaction at all silently does nothing. (Scenario.publish(), from the test library, wraps the publish in its own transaction for exactly this reason.)
  • @Async: the listener runs on a separate thread, after the publishing method has already returned. OrderManagement.placeOrder() finishes and gives the caller an Order back long before inventory has reserved anything.
  • @Transactional(REQUIRES_NEW): the listener gets its own transaction, independent of whatever was happening when the event was published. If the listener fails, it cannot roll back the order that triggered it.
Do not add your own @Transactional on top of this. It looks like harmless extra documentation of intent. It is not. A plain @Transactional (default propagation REQUIRED) stacked on an @ApplicationModuleListener method breaks Spring’s own listener registration, and the whole ApplicationContext refuses to start:
Caused by: java.lang.IllegalStateException: @TransactionalEventListener method must not
be annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED:
public void com.ankurm.modulithdemo.inventory.InventoryManagement.on(com.ankurm.modulithdemo.order.OrderPlaced)
    at org.springframework.transaction.annotation.RestrictedTransactionalEventListenerFactory
        .createApplicationListener(RestrictedTransactionalEventListenerFactory.java:52)
    at org.springframework.context.event.EventListenerMethodProcessor
        .processBean(EventListenerMethodProcessor.java:187)

Full transcript: output/05-redundant-transactional-failure.txt

This is a fail-fast mistake, at least — the context simply will not come up, so it cannot ship silently. It is still worth knowing before it costs you a confused ten minutes staring at a stack trace three frames removed from the line you actually wrote.

Put the three listeners together and one publishEvent() call fans out into four separate invocations, each on its own thread, in causal order but with no ordering guarantee between independent branches:

One publishEvent() call, four listener invocations order inventory shipping notification placeOrder() on(OrderPlaced) on(OrderPlaced) on(StockReserved) on(ShipmentScheduled) Dashed lines are @ApplicationModuleListener hops — each runs async, in its own REQUIRES_NEW transaction, on its own thread.

Running the whole chain for real, from a test that boots all four modules together, produces exactly that:

INFO [task-2] NotificationManagement : Order 1 received for 3x WIDGET-1
INFO [task-4] NotificationManagement : Order 1 is on its way (WIDGET-1)
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- BUILD SUCCESS

Full transcript: output/06-event-flow-passed.txt

Getting that test to run at all — and the very specific way it fails the first time — is its own lesson about a different Spring Modulith default, and it is worth a section of its own.

Testing an event cascade that crosses modules it does not depend on

@ApplicationModuleTest is Spring Modulith’s replacement for @SpringBootTest in a single module’s tests: instead of booting the entire application, it boots just the module under test, plus whichever other modules mode tells it to include. BootstrapMode.ALL_DEPENDENCIES sounds like exactly what you want for an integration test that follows an event across three modules. Here is the first version of that test, written the way the name suggests it should work:

(annotation: @ApplicationModuleTest(mode = BootstrapMode.ALL_DEPENDENCIES) on a test in the order package, publishing OrderPlaced and waiting for ShipmentScheduled)

Bootstrapping @ApplicationModuleTest for Order in mode ALL_DEPENDENCIES
# Order
> Base package: com.ankurm.modulithdemo.order
> Direct module dependencies: none
> Spring beans:
  + ?.OrderController
  + ?.OrderManagement
  o ?.internal.OrderRepository

org.awaitility.core.ConditionTimeoutException: ... expected the predicate to return
<true> but it returned <false> for input of <[]> within 10 seconds.

Full transcript: output/04-event-flow-missing-dependency.txt

Ten seconds of nothing, then a timeout. The log line that explains it is easy to miss on a first read: Direct module dependencies: none. ALL_DEPENDENCIES bootstraps the modules the module under test depends on — the modules it imports from. It does not infer the reverse: modules that depend on it, by listening for its events. order imports nothing from inventory, shipping, or notification — correctly, as the whole first half of this post just finished establishing — so from order’s point of view, in this test, those three modules do not exist. InventoryManagement is never instantiated. The event is published into a context with no listener to receive it, and the test waits for something that was never going to arrive.

ALL_DEPENDENCIES follows your code’s import graph, not your event graph. If the thing you want to test is a reaction to an event — which, in an event-driven module boundary, is most of what there is to test — you have to say so explicitly.

The fix is extraIncludes, which names modules to bootstrap regardless of whether the module under test has a code dependency on them:

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

OrderFulfillmentIntegrationTests.java

With inventory, shipping, and notification named explicitly, their listener beans exist, the cascade actually runs, and this is the test that produced the passing transcript in the previous section. The Scenario API itself — publish(event).andWaitForEventOfType(X.class) .matching(...).toArriveAndVerify(...) — is Spring Modulith’s Awaitility-backed way of asserting on an async, eventually-consistent chain without a hand-rolled Thread.sleep().

What the defaults do not do

Everything so far has been about getting the happy path to compile, pass, and run. Two defaults are worth knowing about before this goes anywhere near production, because neither one fails loudly.

Every @ApplicationModuleListener gets one row in the event publication registry — a table Spring Modulith creates for you (via spring-modulith-starter-jpa or -jdbc) the moment an event is published, before the listener even runs:

One row per @ApplicationModuleListener, per event PUBLISHED PROCESSING COMPLETED FAILED RESUBMITTED listener returns listener throws on retry success A crash between PUBLISHED and PROCESSING leaves a row stuck. The staleness monitor that would mark it FAILED is off by default — all three spring.modulith.events.staleness.* durations default to zero.

That row is what makes the registry useful at all — it is how a listener that threw an exception can be found and resubmitted later, and how a process that died between “published” and “handled” does not just lose the event. But two defaults blunt that guarantee more than the reference documentation’s tone suggests:

  • The default completion mode is UPDATE: completed rows are marked complete and left in the table, forever, growing without bound unless something purges them. DELETE removes them (and loses the ability to query completed publications); ARCHIVE moves them to a separate table first, which is usually the one you actually want.
  • The staleness monitor — the background task that would notice a row stuck in PUBLISHED or PROCESSING after a crash and mark it FAILED so it becomes eligible for resubmission — is disabled by default. All three spring.modulith.events.staleness.* durations default to zero, which this library treats as “off”, not “immediately stale”.
A crash at exactly the wrong moment leaves a row nobody is looking for, by default. Set the staleness durations and a non-UPDATE completion mode before this matters in production:
spring.modulith.events.staleness.published=PT5M
spring.modulith.events.staleness.processing=PT5M
spring.modulith.events.staleness.resubmitted=PT5M
spring.modulith.events.completion-mode=ARCHIVE

Left commented-out in application.properties — every transcript in this post ran with the library’s bare defaults, specifically so this section describes what actually happens without them, not a guess.

None of this is a defect in Spring Modulith — it is a reasonable default for a library that has to work the same way whether you are prototyping on an in-memory H2 database or running a Kafka-backed outbox in production. It is, however, exactly the kind of default that an architecture diagram does not show you, which is the theme of this whole post applied one layer down: the tool that verifies your module boundaries has its own quiet defaults, and they reward the same treatment — read the actual behavior, not the one-paragraph summary.

Generated documentation, not hand-maintained diagrams

The module diagram earlier in this post, and the arrows in it, came from running this:

@Test
void writesDocumentation() {
    new Documenter(modules)
            .writeModulesAsPlantUml()
            .writeIndividualModulesAsPlantUml()
            .writeModuleCanvases()
            .writeAggregatingDocument();
}

ModularityTests.java (the writesDocumentation() test)

Documenter reads the same ApplicationModules model verify() checks and writes a PlantUML component diagram, one PlantUML diagram per module showing just that module’s direct dependencies, a tabular “canvas” per module (Spring beans, grouped by stereotype), and an Asciidoc file linking all of it together — to target/spring-modulith-docs by default.

The raw generated output for this app
Rel(OrderFulfillment.OrderFulfillment.Notification, OrderFulfillment.OrderFulfillment.Order, "listens to", $techn="", $tags="", $link="")
Rel(OrderFulfillment.OrderFulfillment.Shipping, OrderFulfillment.OrderFulfillment.Inventory, "listens to", $techn="", $tags="", $link="")
Rel(OrderFulfillment.OrderFulfillment.Inventory, OrderFulfillment.OrderFulfillment.Order, "listens to", $techn="", $tags="", $link="")
Rel(OrderFulfillment.OrderFulfillment.Notification, OrderFulfillment.OrderFulfillment.Shipping, "listens to", $techn="", $tags="", $link="")

=== target/spring-modulith-docs/module-order.adoc ===
|Spring components
|_Controllers_

* `c.a.m.o.OrderController`

_Services_

* `c.a.m.o.OrderManagement`

Full transcript, including every generated .puml and .adoc file: output/07-generated-docs.txt. Note that the diagram the generator produces is a C4-style PlantUML file meant for a build pipeline to render — this post hand-draws the equivalent SVG instead, to match the rest of the site, but the relationships in it are copied directly from this generated output, not invented.

The practical value of this is less “pretty diagrams” and more “diagrams that cannot quietly go stale”, because they are regenerated from the actual compiled module structure on every build, the same build that is already failing if that structure violates its own rules.

One more failure, worth a sentence, because it will happen to you too

Before any of the module-boundary content above, the very first time this repository’s tests ran against a real database, they failed on something that has nothing to do with Spring Modulith at all:

org.h2.jdbc.JdbcSQLSyntaxErrorException: Syntax error in SQL statement
"create table [*]order (id bigint ..., primary key (id))"; expected "identifier"

Full transcript: output/00-table-name-collision.txt

Hibernate defaults a JPA entity’s table name to the class’s simple name. Order the Java class becomes order the table, and ORDER is a reserved SQL keyword — the ORDER BY clause — in H2, Postgres, MySQL, and the SQL standard generally. The fix is one annotation:

@Entity
@Table(name = "orders")   // ORDER is a reserved SQL keyword (ORDER BY)
public class Order {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String sku;
    private int quantity;
    private String status;
}

Order.java

If you are naming an entity after a core domain concept in an order-processing system, check it against your database’s reserved-word list before the schema generator does it for you. ORDER, GROUP, USER, and DATE have all caught people this way at least once.

Should you reach for this?

Spring Modulith is worth adding the moment more than one person is working in the same Spring Boot codebase and “please don’t reach into that package” has ever been said out loud in a code review. It is not worth adding to a single-developer side project, or to a monolith you are about to split into services anyway — in that case, write the ArchUnit rules for the specific boundary you are about to cut, run them for a few weeks, and extract. ApplicationModules.verify() earns its keep as a standing, automatically-discovered rule set across a whole codebase over months and years, not as a one-time check before a big refactor.

The next post in this series uses this same repository to work through the question that follows naturally from everything above: once your modules are genuinely decoupled and verified, when — if ever — does it actually pay to split them into separate services? And two posts after that, the Transactional Outbox post comes back to the event publication registry diagram above and does something with the completion states this one only described: externalizing events to Kafka, and replaying the ones that got stuck.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.