Years ago, when I was starting my career, I religiously followed the standard textbooks. The ones that teach you that entities should be anemic (just data holders) and that the "service layer" should orchestrate everything.
The result? After just а couple of months in production, the @Service classes bloated into thousands of lines of spaghetti code, filled with endless validation checks, state mutations, and structural mess. The domain entities completely lost their integrity—anyone could mutate their state from anywhere.
And if you throw physical mappings like @ManyToMany into the data access layer later? You are guaranteed to face N+1 query issues, tight coupling, and a schema so tangled that splitting it into microservices feels like performing open-heart surgery.
In this article, we will break down the design of our domain module. It is written in modern Java, remains completely free of Spring dependencies, guarantees encapsulation through a Rich Domain Model, and runs unit tests in milliseconds.
1. Pure OOP (Without Spring Data Magic)
When writing a clean domain model, we must isolate the infrastructure. Instead of relying on Spring Data JPA’s auditing framework at such an early stage, we define the rules for creation, modification, and naming directly within our domain's Java hierarchy.
Here is the blueprint of our base domain classes:
// Every domain object that requires creation audit tracking
public abstract class AbstractCreatable<IdType> {
private IdType id;
private Instant createdAt;
protected AbstractCreatable(IdType id) {
this.id = Objects.requireNonNull(id);
this.createdAt = Instant.now();
}
...
}
// Every domain object that also tracks modification timestamps
public abstract class AbstractUpdatable<IdType>
extends AbstractCreatable<IdType> {
private Instant updatedAt;
protected AbstractUpdatable(IdType id) {
super(id);
this.updatedAt = Instant.now();
}
...
}
// Every domain object requiring a full audit trail (who created and updated it)
public abstract class AbstractAuditable<IdType>
extends AbstractUpdatable<IdType> {
private UUID createdById;
private UUID updatedById;
protected AbstractAuditable(IdType id) {
super(id);
}
protected AbstractAuditable(IdType id, UUID createdById) {
super(id);
this.createdById = Objects.requireNonNull(createdById);
this.updatedById = createdById; // At creation, updatedBy matches creator
}
...
}
💡 But wait... why initialize timestamps directly in the constructor?
Because your domain models must be self-contained and fully functional from the very millisecond they are instantiated. Since our domain module is a pure Java library with zero infrastructure awareness, we cannot and should not rely on database triggers or external framework lifecycles.
By setting Instant.now() directly in the constructor, we guarantee that the domain object is always complete, valid, and immediately testable in-memory, without waiting for some future persistence layer to "hydrate" its state. It also prevents NullPointerException bugs when sorting or processing newly instantiated domain events in memory before database transactions.
2. Death to @ManyToMany – Designing Decoupled Relationships
In real-world, highly scalable systems, physically coupling two independent business contexts (like Product and Category) via Hibernate mappings is an architectural dead-end.
Therefore, in our architecture, @ManyToMany relationships are strictly banned. Instead, we model them as completely decoupled Aggregates that communicate solely through identifiers (UUIDs) using a dedicated Relationship Entity:
// Aggregate Root #1: Category
public class Category extends AbstractAuditable<UUID> {
private String name;
private boolean active;
public Category() {
// POJO
}
...
}
// The decoupled junction: CategoryAssignment (Relationship Entity)
// Since relationship assignments are append-only, inheriting from AbstractCreatable is perfect!
public class CategoryAssignment extends AbstractCreatable<UUID> {
private UUID productId; // Pure UUID instead of Product
private UUID categoryId; // Pure UUID instead of Category
private UUID assignedById; // For auditing purpose, who made the association
public CategoryAssignment(
UUID id,
UUID productId,
UUID categoryId,
UUID assignedById
) {
super(id);
this.productId = Objects.requireNonNull(productId);
this.categoryId = Objects.requireNonNull(categoryId);
this.assignedById = Objects.requireNonNull(assignedById);
}
...
}
Why is this "Distributed Ready"?
If tomorrow your business decides to migrate Categories to a separate microservice (e.g., a dedicated Catalog Service), the code of your Product domain won't change by a single line. The Product database will simply store the categoryId as a plain UUID received via Kafka or a REST payload, without caring about the internal database tables or Hibernate proxies of the neighboring context.
3. When Are Direct Relationships Allowed? (Aggregate Root & 1-to-1)
While we decouple large business units via UUIDs, how do we handle tightly bound details?
If an entity (e.g., ProductSpecification) has no independent business meaning and shares 100% of the lifecycle of the main object (Product), they become part of the same Aggregate Root. When the product is deleted, the specification must be deleted cascadingly.
Here is how we model this in our domain, where Product holds a direct reference to ProductSpecification but acts as the strict transactional boundary:
public class ProductSpecification extends AbstractUpdatable<UUID> {
private String dimensions;
private double weight;
public ProductSpecification(UUID id, String dimensions, double weight) {
super(id);
this.dimensions = Objects.requireNonNull(dimensions);
this.weight = weight;
}
...
}
// Aggregate Root: Product controlling the lifecycle of its nested entities
public class Product extends AbstractAuditable<UUID> {
private String name;
private String sku;
private BigDecimal price;
private ProductStatus status;
private ProductSpecification specifications; // Strongly bound 1-to-1
public Product(
UUID id,
String name,
String sku,
BigDecimal price,
ProductSpecification specifications
) {
super(id);
this.name = Objects.requireNonNull(name);
this.sku = Objects.requireNonNull(sku);
this.price = Objects.requireNonNull(price);
this.specifications = Objects.requireNonNull(specifications);
this.status = ProductStatus.DRAFT;
}
public void transitionTo(ProductStatus nextStatus) {
if (!this.status.canTransitionTo(nextStatus)) {
throw new IllegalStateException("Business rule violated: Cannot transition from " + this.status + " to " + nextStatus);
}
this.status = nextStatus;
}
}
4. State Machine: Inside the Enum or in an External State Manager?
A common debate among senior engineers is: Where should the state transition logic live—inside the Enum or delegated to an external State Manager?
The architectural answer is clear: State transition rules belong to the Domain (inside the Enum), while an external State Manager/orchestrator should only handle structural side-effects.
If you leak state validation rules outside, you anemicize Product and allow any developer to bypass the state machine, mutating the object into an invalid state.
Here is our encapsulated ProductStatus enum:
public enum ProductStatus {
DRAFT {
@Override
public boolean canTransitionTo(ProductStatus next) {
return next == ACTIVE || next == ARCHIVED;
}
},
ACTIVE {
@Override
public boolean canTransitionTo(ProductStatus next) {
return next == OUT_OF_STOCK || next == ARCHIVED;
}
},
OUT_OF_STOCK {
@Override
public boolean canTransitionTo(ProductStatus next) {
return next == ACTIVE || next == ARCHIVED;
}
},
ARCHIVED {
@Override
public boolean canTransitionTo(ProductStatus next) {
return false; // Terminal state. No transitions allowed!
}
};
public abstract boolean canTransitionTo(ProductStatus next);
}
What about the external State Machine? It acts purely as an orchestrator. Once the domain layer successfully performs the transition, the orchestrator commits the transactions and triggers the side-effects (e.g., publishing a ProductActivatedEvent to Kafka or clearing a cache).
5. The 5-Millisecond Unit Test
Since our domain module does not import a single @Component, @Service, or @Autowired annotation, we don't need a slow boot context to validate our business rules.
We test pure Java objects, and the execution completes in under 5 milliseconds:
class ProductDomainTestCase {
private static final UUID CREATOR_ID = UUID.randomUUID();
private static final ProductSpecification spec = new ProductSpecification(...);
private Product product;
@BeforeEach
void setUp() {
product = new Product(...);
}
// --- DRAFT Status Transitions ---
@Test
void shouldAllowTransitionFromDraftToActive() {
product.transitionTo(ProductStatus.ACTIVE);
assertEquals(ProductStatus.ACTIVE, product.getStatus());
}
...
}
By keeping the business layer pure, you establish a highly testable, robust, and decoupled domain core. Frameworks, databases, and network protocols are just external details plugged in at a later stage.
What's Next?
In the next part, we will dive into the very foundation of Data Access Layer design — the DAO API Module — and discuss how to keep it 100% pure without a single specific implementation (hibernate, mysql, oracle sql, etc.).
Codebase & Architecture Blueprint
The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the state established in Chapter 2, use the following link:
-
GitHub Repository (Tag:
chapter-02-domain): advanced-spring-multimodule
Note: All core modules are configured with strict compilation-level boundaries. Compile and run mvn clean install to see the structure in action. Maven version 3.9.* and Java 25 are required.
◀️ Read Chapter 1: Stop returning Spring Data Page from your REST endpoints
▶️ Read Chapter 3: The Dao API Module (Coming soon)
📨 Liked this architecture blueprint? This article is part of my Evolutionary Architecture series. I publish deep-dive technical pieces every week.
👉 Subscribe to my Substack Newsletter to get full source code repositories (Git tags) and new chapters straight to your inbox!
Top comments (0)