Spring, DDD & Architecture Workshop Notes


Spring Security: Upcoming Fixes (May 18)

Rollout date: 18. Mai

Spring releases a batch of security fixes discovered via the Mythos vulnerability check. All branches up to Spring 2.7 are affected.


Domain-Driven Design (DDD)

Extensional vs. Intensional

Term Meaning Examples
Extensional Represents external reality — maps things from outside the domain DTOs, API models, database rows
Intensional Expresses domain concepts from within — gives things meaning Value Objects, Entities, Aggregates

The goal: code that reads like the domain language, not like database schema.

Building Blocks

Entity — has a unique identity that persists over time.

// Identity matters — two Orders with same items are still different Orders
@Entity
public class Order { 
    private OrderId id;
    private String status;
    private List<LineItem> items;
}

Two Order entities with identical items are still different because their id differs.

Value Object — defined by its attributes, not identity. Immutable.

// Two Money(10, EUR) instances are equal — no identity needed
record Money(BigDecimal amount, Currency currency) {}

// Two addresses with same street, city, zip are equal
record Address(String street, String city, String zip) {}

Two Money(10, EUR) instances are equivalent — identity is irrelevant.

Aggregate — consistency boundary. One Aggregate Root controls all internal state.

// Root controls invariants for the whole cluster
@AggregateRoot
public class Order implements AggregateRoot<Order, OrderId> {
    private OrderId id;
    private List<LineItem> items;  // LineItem = Entity inside the Aggregate
    private Money total;           // Value Object
    
    // Only the root can modify internal state
    public void addItem(Product product, int quantity) {
        // Validates business rules before modifying items
        if (isCancelled()) throw new OrderCancelledException();
        items.add(new LineItem(product, quantity));
    }
}

Repository — mediates between the Aggregate and the database.

// Repository deals with Aggregate Roots only
interface OrderRepository {
    void save(Order order);        // Save entire Aggregate
    Order findById(OrderId id);    // Fetch entire Aggregate
    // Never: findLineItem(id) — LineItems are internal!
}

Visual Structure: Aggregate Boundary

┌─────────────────────────────────────────┐
│         Order Aggregate Root            │
│  ┌──────────────────────────────────┐   │
│  │ Order (Entity)                   │   │
│  │ - id: OrderId (Value Object)     │   │
│  │ - total: Money (Value Object)    │   │
│  │ - status: OrderStatus (VO)       │   │
│  │                                  │   │
│  │ ┌──────────────────────────────┐ │   │
│  │ │ LineItem (Entity, internal)  │ │   │
│  │ │ - product: Product (VO)      │ │   │
│  │ │ - quantity: int              │ │   │
│  │ └──────────────────────────────┘ │   │
│  │                                  │   │
│  │ ┌──────────────────────────────┐ │   │
│  │ │ LineItem (Entity, internal)  │ │   │
│  │ │ - product: Product (VO)      │ │   │
│  │ │ - quantity: int              │ │   │
│  │ └──────────────────────────────┘ │   │
│  └──────────────────────────────────┘   │
└─────────────────────────────────────────┘
    ↑
    Only accessible via OrderRepository
    Never access LineItem directly!

Key distinction: Aggregates are fetched/saved via Repositories as a unit. Sub-Entities live only inside an Aggregate and are accessed through it. The Aggregate boundary = the transaction boundary.

Fairbanks (Architecture Fitness Functions)

“Fairbanks” refers to fitness functions from Just Enough Software Architecture (George Fairbanks) — automated checks that verify architectural rules continuously. Think: ArchUnit tests that fail the build if a module boundary is violated.

DDD Concepts at Different Scales

Concept Scope Responsibility
Value Object Single attribute or small cluster Immutable, logic-free, equality by value
Entity Identified member Has identity, lifespan, behavior
Aggregate Consistency boundary Enforces invariants, manages sub-entities
Bounded Context Multiple Aggregates Shared language, distinct from other contexts
Domain Event Cross-Aggregate communication Something important happened; other Aggregates may react

jMolecules

GitHub: https://github.com/odrotbohm/tactical-ddd-workshop

jMolecules bridges DDD concepts and Java code. Three layers of value:

1. Expressive Annotations & Interfaces

// Marker interfaces provide generic type safety
public class Order implements AggregateRoot<Order, OrderId> { }
public class OrderId implements Identifier<Order> { }

Annotations like @Aggregate, @Entity, @ValueObject make intent explicit — not just for humans, but for tooling.

2. ArchUnit Verification

jMolecules ships ready-made ArchUnit rulesets. Add to your test suite:

@AnalyzeClasses(packagesOf = Application.class)
class DddRulesTest {
    @ArchTest
    ArchRule dddRules = JMoleculesDddRules.all();
}

This enforces: Aggregates only access other Aggregates via Associations, Repositories only handle Aggregate Roots, etc.

3. ByteBuddy Plugin — Boilerplate Elimination

The problem: JPA requires a ton of annotations that have nothing to do with the domain:

// JPA-heavy noise
@javax.persistence.Entity
@Table(name = "orders")
public class Order {
    @Id @GeneratedValue private Long id;
    @OneToMany(cascade = CascadeType.ALL) private List<LineItem> items;
}

With jMolecules + ByteBuddy plugin: The Maven/Gradle plugin reads your DDD annotations at compile time and generates the JPA mappings automatically.

// Clean domain model — JPA annotations added at build time
@Aggregate
public class Order {
    private OrderId id;
    private List<LineItem> items;
}

This makes the code framework-independent — swap JPA for something else later without touching domain classes.

Logical Structure View & Stereotype Annotations

Using @Aggregate, @DomainService, @Repository as stereotype annotations enables tooling to show the abstraction layer of the code, not just the folder structure.

Why stereotypes beat packages for structure:

AI angle: New stereotype-based patterns are more machine-readable. LLMs can reason about @Aggregate boundaries more reliably than inferring structure from arbitrary package names.

Visualizing DDD with Tooling

Once annotated with jMolecules, the codebase becomes self-documenting:

Before tooling (folders only):

src/main/java/com/shop
├── order
│   ├── OrderController.java
│   ├── OrderService.java
│   └── OrderRepository.java
├── customer
│   ├── CustomerService.java
│   └── CustomerRepository.java
└── payment
    └── PaymentProcessor.java

→ Looks like layered architecture, unclear which are Aggregates.

With jMolecules annotations (tooling renders the abstraction):

📦 OrderAggregate (com.shop.order)
  ├── 🔑 Order @Aggregate
  ├── 🧪 LineItem @Entity
  ├── 💰 Money @ValueObject
  └── 📦 OrderRepository @Repository

📦 CustomerAggregate (com.shop.customer)
  ├── 🔑 Customer @Aggregate
  ├── 📧 Email @ValueObject
  └── 📦 CustomerRepository @Repository

📦 Payment (com.shop.payment)
  ├── 🔑 Payment @Aggregate
  └── ⚡ PaymentService @DomainService

→ Immediately visible: what’s a root, what’s internal, what enforces consistency.

IDE Integration (planned for IntelliJ):


Spring Modulith

Repo: https://github.com/odrotbohm/arch-evident-spring
Blog post: https://odrotbohm.de/2025/12/rethinking-spring-application-integration-testing/

Core Idea: Sliced Onion Architecture

Traditional layered architecture splits code horizontally (web → service → repository). This creates tight coupling across features.

Spring Modulith splits vertically — by business capability:

com.example
  ├── order/        ← module: public API + internal impl
  │   ├── OrderController.java    (public)
  │   ├── OrderService.java       (public)
  │   └── internal/
  │       └── OrderRepository.java  (package-private = module-internal)
  ├── inventory/    ← module
  └── customer/     ← module

Rule: Only public types in a module package are accessible to other modules. Package-private = module boundary enforced at test/build time.

Module Integration

Three levels, from tight to loose coupling:

Approach Coupling When to use
Direct bean injection High Avoid across modules
Synchronous domain events Medium Same transaction needed
Async transactional listeners Low Eventual consistency OK
// Async event listener — Order module doesn't know about Inventory
@TransactionalEventListener
@Async
void on(OrderCompleted event) {
    inventory.reduce(event.getItems());
}

Event Publication Registry

Problem: async listener fails after event published → event lost, data inconsistent.

Spring Modulith solves this with an Event Publication Registry — persists the event before dispatching, marks it complete only after successful handler execution. Unpublished events can be retried.

Testing with @ApplicationModuleTest

Standard Spring @SpringBootTest boots the entire application. Spring Modulith’s annotation boots only the relevant module + declared dependencies:

@ApplicationModuleTest
class OrderModuleTest {
    // Only Order module beans available — Inventory is mocked/excluded
    @Test
    void placingOrderPublishesEvent(Scenario scenario) {
        scenario.stimulate(() -> orders.place(new OrderRequest(...)))
                .andWaitForEventOfType(OrderCompleted.class)
                .toArrive();
    }
}

Benefits vs. classic integration tests:

Observability

Spring Modulith exposes an Actuator endpoint with the module dependency graph as JSON — machine-readable architecture documentation that’s always up to date.


Book Recommendations

Book Author Why relevant
Sustainable Software Architecture Carola Lilienthal Keeping architecture clean long-term, managing complexity
Domain-Driven Design Eric Evans The original — Aggregates, Bounded Contexts, Ubiquitous Language
Just Enough Software Architecture George Fairbanks Fitness functions, risk-driven architecture, the “Fairbanks” concept from the talk