Stop Pretending Your Java Architecture Is Clean: jMolecules 2.0, ArchUnit, and Spring Modulith Do the Heavy Lifting
A guide to making your codebase architecturally self-aware with the stereotype metamodel, automated enforcement, and verified module boundaries
The Invisible Architecture Problem: A Dark Comedy
Your architecture is a beautiful lie. You've got packages named domain, application, infrastructure. Someone drew fancy diagrams. But here's the truth: when the compiler runs, nobody's watching. Controllers call repositories directly. Services reach into other modules' internals. Aggregates reference five other aggregates because "it was easier." The build passes. The tests pass. Nobody stops you.
Architectural rules have zero enforcement. They're suggestions wrapped in convention, enforced by hope and code reviews. After six months, your "clean architecture" is indistinguishable from a monolithic mess where everyone imports from everyone. Documentation becomes fiction. Architecture becomes invisible.
This doesn't have to be the status quo.
jMolecules 1.x: A Decent Start (But Not Enough)
jMolecules 1.x (2020) was promising. You could explicitly declare @AggregateRoot, @Entity, @ValueObject. Framework-agnostic. Elegant. But frameworks still guessed at your intent. Spring, JPA, Jackson interpreted annotations differently. ByteBuddy magic felt hacky. And Java 8 support meant dragging decades of compatibility baggage.
Tools couldn't agree on what your annotations meant. Multiple interpretations. Zero unified understanding. Integration required manual tweaking. Close, but not transformative.
jMolecules 2.0: When Architecture Becomes Metadata (The Real Magic)
November 2025: jMolecules 2.0 dropped and solved the guessing game. The Stereotype Metamodel.
jMolecules 2.0 supports a rich range of architectural styles out of the box, including Domain-Driven Design (DDD) building blocks (like AggregateRoot, Entity, ValueObject, Repository), classical Layered Architecture, Onion Architecture, and Hexagonal Architecture with Ports and Adapters. It also now natively supports CQRS, event-driven, and custom domain patterns through its extensible stereotype metamodel — letting you declare new stereotypes so your organization's unique architecture rules are first-class, discoverable, and enforceable alongside all the well-known patterns.
Every jMolecules 2.0 artifact ships with META-INF/jmolecules-stereotypes.json — machine-readable architectural knowledge that every tool understands identically:
{
"stereotype": {
"name": "AggregateRoot",
"description": "DDD aggregate root.",
"tags": ["ddd", "domain-model"],
"constraints": {
"mustImplement": ["org.jmolecules.ddd.types.AggregateRoot"],
"canDepend": ["ValueObject", "Entity"],
"cannotDepend": ["infrastructure.*"]
}
}
}Now your IDE, ArchUnit, Spring Data, Jackson, and documentation generators all read the same file. No more guessing. They're all on the same page for the first time.
You can extend the metamodel with custom stereotypes for your organization:
{
"stereotype": {
"name": "LegacyAdapter",
"constraints": { "cannotDepend": ["ddd.core.*"] }
}
}The entire ecosystem enforces your custom patterns. Governance at scale.
Java 17+: No Compromises
jMolecules 2.0 dropped Java 8 support entirely. Java 17+ only. This unlocked records, sealed classes, and modules. No legacy cruft.
Modern domain models with sealed records enforce rules through the type system:
@AggregateRoot
public sealed record Order(
OrderId id, CustomerId customerId, List<OrderItem> items, OrderStatus status
) implements AggregateRoot<Order, OrderId>
permits PendingOrder, CompletedOrder {
public Order complete() {
if (this instanceof CompletedOrder) throw new IllegalStateException("Done");
return new CompletedOrder(id, customerId, items, status);
}
}
@ValueObject
public record Money(BigDecimal amount, Currency currency) {
public Money { // compact constructor
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Negative");
}
}
}Invalid state transitions become compile-time errors. Immutability is built in. Boilerplate vanishes. The compiler becomes your architect.
ArchUnit: Your Build Cop Who Actually Shows Up
ArchUnit analyzes bytecode and verifies architectural rules as JUnit tests. Violate a rule? The test fails. Immediately. No code review needed.
With jMolecules 2.0, ArchUnit auto-generates rules from the stereotype metadata:
@AnalyzeClasses(packages = "com.example.shop")
public class ArchitectureTests {
@ArchTest
static final ArchRule dddRules = JMoleculesDddRules.all(); // Reads metadata
@ArchTest
static final ArchRule noCyclicDependencies =
slices().matching("com.example.shop.(*)..")
.should().beFreeOfCycles();
@ArchTest
static final ArchRule noFieldInjection =
GeneralCodingRules.NO_CLASSES_SHOULD_USE_FIELD_INJECTION;
}No manual rule writing — the metadata handles it. "Aggregates can't depend on infrastructure"? It's in the metadata. Automatically enforced.
Need to manage existing technical debt? Use freeze rules for ratcheting:
@ArchTest
static final ArchRule frozenRules = FreezingArchRule
.freeze(JMoleculesDddRules.all())
.persistIn(new TextFileBasedViolationStore("violations.txt"));Current violations are grandfathered in. New violations fail the test. Can't make things worse, only better.
Spring Modulith: Modular Monoliths That Don't Lie
Microservices are sexy — until you're debugging across five services and maintaining Kubernetes just to deploy "hello world." Spring Modulith is the modular monolith: one deployable unit, multiple verified boundaries, event-driven communication without network calls.
Module Boundaries: Verified
Spring Modulith treats direct sub-packages as modules. Internal packages are hidden by default:
com.example.shop/
├── orders/ <- Module (public)
│ └── internal/ <- Internal (hidden)
├── inventory/ <- Module
└── notifications/ <- ModuleVerify boundaries with a single test:
@Test
void verifyModuleStructure() {
ApplicationModules.of(ShopApplication.class).verify();
}No cyclic dependencies, modules only access public APIs, no reaching into internals. Violations fail immediately.
Event-Driven Communication with Guarantees
Instead of direct coupling, modules communicate via events:
@Service
public class OrderService {
@Transactional
public void completeOrder(Order order) {
order.markAsComplete();
repository.save(order);
events.publishEvent(new OrderCompletedEvent(order.getId()));
}
}
@Service
public class InventoryService {
@ApplicationModuleListener
void onComplete(OrderCompletedEvent event) {
decreaseStock(event.getOrderId());
}
}The @ApplicationModuleListener runs asynchronously in a separate transaction, decoupling modules completely.
Spring Modulith's Event Publication Registry guarantees delivery — events are persisted in the same transaction as your business logic (transactional outbox pattern), then asynchronously delivered with automatic retries:
spring.modulith.events.republish-outstanding-events-on-restart=trueEvents are never lost. Retry logic is automatic. Single deployable unit.
Auto-Generated Documentation
@Test
void generateDocumentation() {
new Documenter(ApplicationModules.of(ShopApplication.class))
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml()
.writeModuleCanvases();
}Generates component diagrams, event flows, module documentation — all auto-generated from your code. Your diagrams are always current.
The Dream Team: Putting It All Together
Each tool solves a specific problem:
- jMolecules 2.0: Makes architecture explicit (annotations + metadata)
- ArchUnit: Enforces rules (automated testing)
- Spring Modulith: Runtime enforcement (boundaries + events)
Together: architecture visible, verifiable, automatically enforced.
Setup
<dependencyManagement>
<dependency>
<groupId>org.jmolecules</groupId>
<artifactId>jmolecules-bom</artifactId>
<version>2025.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.jmolecules</groupId>
<artifactId>jmolecules-ddd</artifactId>
</dependency>
<dependency>
<groupId>org.jmolecules.integrations</groupId>
<artifactId>jmolecules-spring</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-core</artifactId>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>Configure ByteBuddy:
<plugin>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-maven-plugin</artifactId>
<version>1.17.7</version>
<executions>
<execution><goals><goal>transform</goal></goals></execution>
</executions>
<dependencies>
<dependency>
<groupId>org.jmolecules.integrations</groupId>
<artifactId>jmolecules-bytebuddy</artifactId>
<version>0.30.0</version>
</dependency>
</dependencies>
</plugin>Install IntelliJ plugin (search "jMolecules" in marketplace). Real-time violations in your IDE.
Real-World Example: Order Management System
Domain Model (Sealed Records)
@AggregateRoot
public sealed record Order(
OrderId id, CustomerId customerId, List<OrderItem> items, OrderStatus status
) implements AggregateRoot<Order, OrderId>
permits PendingOrder, CompletedOrder {
public static Order create(OrderId id, CustomerId customerId) {
return new PendingOrder(id, customerId, List.of(), OrderStatus.PENDING);
}
public Order complete() {
if (this instanceof CompletedOrder) throw new IllegalStateException("Already done");
return new CompletedOrder(id, customerId, items, OrderStatus.COMPLETED);
}
}
@Entity
public record OrderItem(String productId, int quantity, Money price)
implements Entity<Order, OrderItem> {}
@ValueObject
public record Money(BigDecimal amount, Currency currency) {
public Money { if (amount.compareTo(ZERO) < 0) throw new IllegalArgumentException(); }
}
@DomainEvent
public record OrderCompletedEvent(OrderId orderId, CustomerId customerId, List<String> productIds) {}Service with Events
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository repository;
private final ApplicationEventPublisher events;
@Transactional
public void completeOrder(OrderId orderId) {
Order order = repository.findById(orderId).orElseThrow();
Order completed = order.complete();
repository.save(completed);
events.publishEvent(new OrderCompletedEvent(
orderId, order.customerId(),
order.items().stream().map(OrderItem::productId).toList()
));
}
}Event Listeners
@Service
public class InventoryService {
@ApplicationModuleListener
void on(OrderCompletedEvent event) {
event.productIds().forEach(productId -> {
Product product = repository.findByProductId(productId).orElseThrow();
product.decreaseStock();
repository.save(product);
});
}
}ArchUnit Tests
@AnalyzeClasses(packages = "com.example.shop")
public class ArchitectureTests {
@ArchTest
static final ArchRule stereotypes = JMoleculesDddRules.all();
@ArchTest
static final ArchRule modules = ApplicationModules.of(ShopApplication.class).verify();
@ArchTest
static final ArchRule noCycles =
slices().matching("com.example.shop.(*)..")
.should().beFreeOfCycles();
}The Good, The Bad, and The Sealed
Benefits: Automated architectural governance. Visible, verifiable, automatically enforced. Architecture becomes self-aware. IDEs warn you. Tests fail on violations. Documentation is auto-generated and always accurate. Domain models are bulletproof (type-safe, immutable, compiler-enforced).
Trade-offs: Java 17+ only (no Java 11 backport). Immutability requires mental restructuring. Custom stereotypes add complexity. Learning curve is real.
When to use: Green field projects? Absolutely. Existing projects on Java 17+? Migrate incrementally. Legacy systems on Java 11? Stick with jMolecules 1.x until you upgrade.
Modulith vs. Microservices: Use moduliths when services don't need independent scaling, teams deploy together, and operational simplicity matters. Pick microservices when you genuinely need independent scaling, separate teams, different tech stacks, or complete isolation.
Conclusion: Architecture That Explains Itself
For decades, Java architecture lived in design documents and developers' heads. It wasn't enforced — it was hoped for.
jMolecules 2.0, ArchUnit, and Spring Modulith end this. Architecture is now metadata. Verifiable. Automatically enforced. The stereotype metamodel connects human intent and machine action. Records and sealed classes make domain models self-aware. ArchUnit auto-generates rules. Spring Modulith enforces boundaries at runtime. Your IDE warns you before violations.
Is it more work upfront? Yes. But watching your architecture dissolve into chaos is worse.
The tools are mature. The patterns are proven. The ecosystem is aligned. Your codebase is ready to speak the truth. Time to listen.