How to Build Software That Bends Without Breaking
“Good architecture allows you to change your mind without changing the world.”
Prologue: The Cathedral of Complexity
It started with a simple user registration feature.
Six months later, the codebase had grown into something unrecognizable. Thirty-seven classes to create a user account. Six different DTOs for what was essentially name, email, and password. A domain event system that published “UserRegistered” events nobody consumed. Bounded contexts that mapped 1:1 to database tables.
The team had followed every DDD pattern in the book. They’d created aggregates for entities with no invariants. They’d defined repositories with specification patterns for simple CRUD. They’d built an event sourcing system for data that never changed.
The result? A codebase so complex that adding a new field required touching 12 files. Unit tests that took 3 minutes to start because they needed the Spring context. New developers who spent their first week just trying to understand where business logic lived.
This is the dark side of Domain-Driven Design. When followed dogmatically, it doesn’t simplify - it bloats.
Imagine you’re constructing a house. The Domain is the foundation - poured concrete, load-bearing walls, the very skeleton that keeps everything standing. The Application layer is the plumbing and electrical—connecting systems, directing flow, but never deciding where rooms should go. Infrastructure is the facade and fixtures—the paint, the windows, the doorknobs that can be swapped without touching the structure.
For decades, developers have been handed rigid architectural blueprints. DDD purists insist on every wall, every beam, every measurement following sacred patterns. But pragmatic DDD asks a different question: Does this room need a cathedral ceiling, or will standard height do?
The revolution is not in the complexity - it is in the selective application. In this guide, we will explore how to build systems that are robust without being over-engineered, clear without being bureaucratic, and maintainable without requiring a PhD in design patterns.
Part I: When DDD Saves You (And When It Doesn’t)
The Decision Matrix
Before writing a single line of code, determine if DDD fits your context:
| Context | Use DDD? | Why | Example |
|---|---|---|---|
| Complex business rules, multiple paths through workflows | Yes | Business logic centralization prevents scattering | E-commerce checkout with discounts, taxes, inventory |
| Long-term project (2+ years), evolving requirements | Yes | Boundaries protect against future changes | Enterprise billing system with changing regulations |
| Multiple teams, changing developers | Yes | Clear structure reduces onboarding friction | Banking platform with 5+ teams contributing |
| Simple CRUD, no complex rules | No | Transaction scripts are faster to write and maintain | Blog post CMS - create, read, update, delete only |
| Rapid prototype/MVP | No | YAGNI (You Are not Gonna Need It) - validate first | Startup landing page with contact form |
| Team unfamiliar with DDD | No | Wrong abstractions are worse than no abstractions | Junior team with tight deadline |
The Evolution Strategy
Don’t start with DDD. Start simple, evolve as complexity demands:
Phase 1 (Weeks 1-4): Transaction Script
↓
Phase 2 (Months 2-3): Anemic Domain Model
↓
Phase 3 (Months 4-6): Rich Domain Model
↓
Phase 4 (Year 1+): Full DDD (only if needed)
Transaction Script Pattern: Business logic lives in service methods, not domain objects. Each method represents a single transaction (e.g., registerUser(), processOrder()). Simple, fast to write, perfect for CRUD apps. See Part VI for a before/after example.
Start at your current complexity level, not where you think you’ll be.
Case Study: Bounded Contexts Decision
Consider a small team building a user management system:
Scenario: 3 developers, 1 domain language, straightforward authentication
Over-engineered approach:
// ❌ Separate contexts add ceremony without value
@Context("UserManagement")
public class UserContext { ... }
@Context("Authentication")
public class AuthContext { ... }
Pragmatic approach:
// ✅ One context with clear package separation
/domain/user/User.java
/domain/auth/Password.java
Decision factors:
- Team size: Small (3 developers)
- Domain complexity: Low (basic CRUD)
- Language differences: None (unified terms)
- Result: Package-level separation is sufficient
Part II: Anatomy of a Clean Architecture
The Dependency Compass
Your code doesn’t just “run.” It obeys directional gravity:
Presentation ──▶ Application ──▶ Domain
▲ ▲
│ │
Infrastructure ───────┴─────────────┘
Dependencies flow inward. Domain has zero dependencies. Infrastructure implements domain interfaces. Application coordinates them.
Critically: the domain knows nothing of Spring, JPA, or HTTP.
Request Flow: End to End
HTTP Request
↓
AuthController (Presentation)
↓ Validates DTO, converts to Request object
RegisterUseCase.execute() (Application)
↓ Orchestrates, no business logic
User.register() (Domain)
↓ Enforces invariants, creates aggregate
UserRepository.save() (Port)
↓ Implemented by...
JpaUserRepositoryAdapter (Infrastructure)
↓ Maps to...
UserJpaEntity → Database
↓
TokenProvider.generateToken()
↓
AuthResponse (DTO) → HTTP Response
Each layer has exactly one responsibility. The controller doesn’t validate business rules. The use case doesn’t persist data. The domain doesn’t know about HTTP.
Part III: Core Building Blocks
Layer 1: Domain (The Foundation)
Purpose: Encapsulate business rules and invariants.
Characteristics:
- Zero dependencies on frameworks (no Spring, no JPA, no HTTP)
- Pure Java with business logic
- Protects invariants at all costs
- The only layer that truly matters for business value
Complete Example - User Aggregate:
// Value Object: UserId
public record UserId(UUID value) {
public UserId {
Objects.requireNonNull(value, "User ID cannot be null");
}
public static UserId generate() {
return new UserId(UUID.randomUUID());
}
}
// Value Object: Email with validation
public record Email(String value) {
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$");
public Email {
if (value == null || !EMAIL_PATTERN.matcher(value).matches()) {
throw new InvalidEmailException(value);
}
}
}
// Value Object: Password with hashing
public class Password {
private final String hashedValue;
private Password(String hashed) {
this.hashedValue = hashed;
}
public static Password create(String plainText, PasswordEncoder encoder) {
if (plainText == null || plainText.length() < 8) {
throw new InvalidPasswordException("Password must be at least 8 characters");
}
return new Password(encoder.encode(plainText));
}
public boolean matches(String plainText, PasswordEncoder encoder) {
return encoder.matches(plainText, this.hashedValue);
}
public String hashedValue() {
return hashedValue;
}
}
// Aggregate Root: User
public class User {
private final UserId id;
private final Email email;
private Password password;
private String name;
private Role role;
private boolean banned;
private final Instant createdAt;
// Constructor uses flexible constructor bodies (Java 25)
// Validation happens BEFORE field assignment
public User(UserId id, Email email, Password password, String name) {
Objects.requireNonNull(id, "User ID required");
Objects.requireNonNull(email, "Email required");
Objects.requireNonNull(password, "Password required");
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name required");
}
// Only assign after validation passes
this.id = id;
this.email = email;
this.password = password;
this.name = name;
this.role = Role.USER;
this.banned = false;
this.createdAt = Instant.now();
}
// Factory method: Ensures users always start with sensible defaults
// - Automatically generates ID
// - Sets default role to USER
// - Sets created timestamp
// This is the ONLY way to create a User in the domain
public static User register(Email email, Password password, String name) {
return new User(UserId.generate(), email, password, name);
}
// Business operations with invariant protection
public void changePassword(Password newPassword) {
Objects.requireNonNull(newPassword, "Password required");
// Note: In production, you might check password history here
// to prevent reuse of recent passwords (requires storing history)
this.password = newPassword;
}
public void ban(String reason) {
if (role == Role.ADMIN) {
throw new IllegalStateException("Cannot ban administrators");
}
this.banned = true;
}
public void unban() {
this.banned = false;
}
public void promoteToAdmin() {
if (banned) {
throw new IllegalStateException("Cannot promote banned users");
}
this.role = Role.ADMIN;
}
// Business query methods
public boolean canLogin() {
return !banned && role != Role.SUSPENDED;
}
// Only expose what external layers need
public UserId id() { return id; }
public Email email() { return email; }
public Role role() { return role; }
public boolean isBanned() { return banned; }
}
Why this works:
User.javacontains ALL business rules - single source of truth- Zero framework dependencies - pure Java
- Tests run in <10ms without Spring context
- Invalid states are unrepresentable
Layer 2: Application (The Plumbing)
Purpose: Orchestrate use cases. No business logic, just coordination.
Complete Example - RegisterUseCase:
@Component
public class RegisterUseCase {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final TokenProvider tokenProvider;
public RegisterUseCase(
UserRepository userRepository,
PasswordEncoder passwordEncoder,
TokenProvider tokenProvider) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.tokenProvider = tokenProvider;
}
@Transactional
public AuthResponse execute(RegisterRequest request) {
// 1. Create value objects (validation happens in constructors)
final var email = new Email(request.email());
// 2. Check business rule (orchestration, not logic)
if (userRepository.existsByEmail(email)) {
throw new EmailAlreadyExistsException(request.email());
}
// 3. Create domain object (business rules enforced inside)
final var user = User.register(
email,
Password.create(request.password(), passwordEncoder),
request.name()
);
// 4. Persist through port
userRepository.save(user);
// 5. Generate token (infrastructure concern)
final var token = tokenProvider.generateToken(user);
// 6. Return response DTO
return new AuthResponse(token, UserDto.from(user));
}
}
// Application DTOs are simple records
public record RegisterRequest(String email, String password, String name) {}
public record AuthResponse(String token, UserDto user) {}
public record UserDto(String id, String email, String name, String role) {
public static UserDto from(User user) {
return new UserDto(
user.id().value().toString(),
user.email().value(),
user.name(),
user.role().name()
);
}
}
Layer 3: Infrastructure (The Facade)
Purpose: Implement technical details. Adapters for external concerns.
Repository Port (Domain):
// Domain defines the contract
public interface UserRepository {
Optional<User> findById(UserId id);
Optional<User> findByEmail(Email email);
boolean existsByEmail(Email email);
User save(User user);
void delete(UserId id);
}
Repository Adapter (Infrastructure):
@Repository
public class JpaUserRepositoryAdapter implements UserRepository {
private final UserJpaRepository jpaRepository;
private final UserDomainMapper mapper;
public JpaUserRepositoryAdapter(
UserJpaRepository jpaRepository,
UserDomainMapper mapper) {
this.jpaRepository = jpaRepository;
this.mapper = mapper;
}
@Override
public Optional<User> findByEmail(Email email) {
return jpaRepository.findByEmail(email.value())
.map(mapper::toDomain);
}
@Override
public User save(User user) {
var entity = mapper.toEntity(user);
jpaRepository.save(entity);
return user;
}
@Override
public boolean existsByEmail(Email email) {
return jpaRepository.existsByEmail(email.value());
}
}
Spring Data Repository (Auto-generated by Spring Boot):
// Spring Data JPA automatically implements this interface
// No implementation class needed - Spring generates it at runtime
@Repository
public interface UserJpaRepository extends JpaRepository<UserJpaEntity, String> {
Optional<UserJpaEntity> findByEmail(String email);
boolean existsByEmail(String email);
}
Domain Mapper (Infrastructure):
@Component
public class UserDomainMapper {
public User toDomain(UserJpaEntity entity) {
return new User(
new UserId(UUID.fromString(entity.getId())),
new Email(entity.getEmail()),
new Password(entity.getPasswordHash()),
entity.getName()
);
}
public UserJpaEntity toEntity(User user) {
var entity = new UserJpaEntity();
entity.setId(user.id().value().toString());
entity.setEmail(user.email().value());
entity.setPasswordHash(user.password().hashedValue());
entity.setName(user.name());
entity.setRole(user.role());
entity.setBanned(user.isBanned());
entity.setCreatedAt(Instant.now());
return entity;
}
}
JPA Entity (Infrastructure):
@Entity
@Table(name = "users")
public class UserJpaEntity {
@Id
private String id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String passwordHash;
@Column(nullable = false)
private String name;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@Column(nullable = false)
private boolean banned;
@Column(nullable = false)
private Instant createdAt;
// Getters and setters
}
Layer 4: Presentation (The Front Door)
Purpose: Handle HTTP, validation, and conversion.
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final RegisterUseCase registerUseCase;
public AuthController(RegisterUseCase registerUseCase) {
this.registerUseCase = registerUseCase;
}
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(
@Valid @RequestBody RegisterRequestDto dto) {
final var response = registerUseCase.execute(dto.toRequest());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
// Presentation DTO with validation
public record RegisterRequestDto(
@NotBlank @Email String email,
@NotBlank @Size(min = 8) String password,
@NotBlank String name
) {
public RegisterRequest toRequest() {
return new RegisterRequest(email, password, name);
}
}
Part IV: Testing Strategy
Domain Layer Tests (Fast, No Context)
@DisplayName("User Domain Logic")
class UserTest {
private PasswordEncoder encoder = new BCryptPasswordEncoder();
@Test
@DisplayName("Cannot create user with invalid email")
void invalidEmailThrowsException() {
assertThrows(InvalidEmailException.class, () -> {
new Email("not-an-email");
});
}
@Test
@DisplayName("Banned users cannot login")
void bannedUserCannotLogin() {
var user = createValidUser();
user.ban("Spam");
assertFalse(user.canLogin());
}
@Test
@DisplayName("Cannot promote banned user to admin")
void cannotPromoteBannedUser() {
var user = createValidUser();
user.ban("Violation");
assertThrows(IllegalStateException.class, () -> {
user.promoteToAdmin();
});
}
@Test
@DisplayName("Password change rejects reused passwords")
void passwordHistoryEnforced() {
var user = createValidUser();
var newPassword = Password.create("newPassword123", encoder);
user.changePassword(newPassword);
// Password successfully changed
assertTrue(user.password().matches("newPassword123", encoder));
}
// Tests run in <10ms each - no Spring context needed
}
Metrics: Domain tests run in milliseconds without Spring context - orders of magnitude faster than integration tests.
Application Layer Tests (With Mocks)
@ExtendWith(MockitoExtension.class)
@DisplayName("Register Use Case")
class RegisterUseCaseTest {
@Mock UserRepository repository;
@Mock PasswordEncoder encoder;
@Mock TokenProvider tokenProvider;
RegisterUseCase useCase;
@BeforeEach
void setup() {
useCase = new RegisterUseCase(repository, encoder, tokenProvider);
}
@Test
@DisplayName("Successfully registers new user")
void successfulRegistration() {
// Given
when(repository.existsByEmail(any())).thenReturn(false);
when(encoder.encode(any())).thenReturn("hashed");
when(tokenProvider.generateToken(any())).thenReturn("token123");
// When
var result = useCase.execute(
new RegisterRequest("john@example.com", "password123", "John")
);
// Then
assertNotNull(result);
assertEquals("token123", result.token());
verify(repository).save(any(User.class));
}
@Test
@DisplayName("Throws when email already exists")
void duplicateEmailThrowsException() {
when(repository.existsByEmail(any())).thenReturn(true);
assertThrows(EmailAlreadyExistsException.class, () -> {
useCase.execute(new RegisterRequest("exists@example.com", "pass", "Name"));
});
verify(repository, never()).save(any());
}
}
Integration Tests (Full Stack)
@SpringBootTest
@AutoConfigureMockMvc
@DisplayName("Auth Controller Integration")
class AuthControllerIntegrationTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper mapper;
@Test
@DisplayName("Registers user and returns token")
void fullRegistrationFlow() throws Exception {
var request = new RegisterRequestDto(
"integration@test.com", "password123", "Test User"
);
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.token").exists())
.andExpect(jsonPath("$.user.email").value("integration@test.com"));
}
}
Part V: Error Handling Strategy
Exception Hierarchy by Layer
domain/
exception/
DomainException.java // Base domain exception
InvalidEmailException.java // Business rule violation
EmailAlreadyExistsException.java
InsufficientFundsException.java
application/
exception/
ApplicationException.java // Use case failures
AuthenticationFailedException.java
presentation/
exception/
GlobalExceptionHandler.java // Maps to HTTP responses
ApiError.java
Domain Exceptions (Business Rules)
// Base exception for domain layer
public abstract class DomainException extends RuntimeException {
public DomainException(String message) {
super(message);
}
}
// Specific business rule violations
public class InvalidEmailException extends DomainException {
private final String email;
public InvalidEmailException(String email) {
super("Invalid email format: " + email);
this.email = email;
}
public String getEmail() { return email; }
}
public class EmailAlreadyExistsException extends DomainException {
public EmailAlreadyExistsException(String email) {
super("Email already registered: " + email);
}
}
Exception Translation Flow
// 1. Domain throws specific exception
public class User {
public void changePassword(Password newPassword) {
if (isRecentPassword(newPassword)) {
throw new InvalidPasswordException("Cannot reuse recent passwords");
}
}
}
// 2. Application may catch and wrap (or let propagate)
@Component
public class ChangePasswordUseCase {
public void execute(ChangePasswordRequest request) {
try {
user.changePassword(new Password(request.newPassword()));
} catch (InvalidPasswordException e) {
// Log and rethrow, or wrap if needed
audit.logFailedPasswordChange(request.userId());
throw e; // Let it propagate
}
}
}
// 3. Presentation maps to HTTP responses
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InvalidEmailException.class)
public ResponseEntity<ApiError> handleInvalidEmail(InvalidEmailException ex) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ApiError("INVALID_EMAIL", ex.getMessage()));
}
@ExceptionHandler(EmailAlreadyExistsException.class)
public ResponseEntity<ApiError> handleEmailExists(EmailAlreadyExistsException ex) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ApiError("EMAIL_EXISTS", ex.getMessage()));
}
@ExceptionHandler(DomainException.class)
public ResponseEntity<ApiError> handleDomainException(DomainException ex) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ApiError("DOMAIN_ERROR", ex.getMessage()));
}
}
Part VI: Migration Strategy
From Transaction Script to Rich Domain
Before (Transaction Script):
@Service
public class UserService {
@Autowired private UserRepository repo;
@Autowired private PasswordEncoder encoder;
public void registerUser(String email, String password, String name) {
// Validation scattered
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("Invalid email");
}
if (password == null || password.length() < 8) {
throw new IllegalArgumentException("Password too short");
}
if (repo.existsByEmail(email)) {
throw new RuntimeException("Email exists");
}
// Business logic in service
UserEntity entity = new UserEntity();
entity.setEmail(email);
entity.setPasswordHash(encoder.encode(password));
entity.setName(name);
entity.setRole("USER");
entity.setCreatedAt(Instant.now());
repo.save(entity);
}
}
After (Rich Domain Model):
// Domain now enforces its own rules
public class User {
public User(UserId id, Email email, Password password, String name) {
// Validation in constructor
Objects.requireNonNull(email);
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name required");
}
// ...
}
public static User register(Email email, Password password, String name) {
return new User(UserId.generate(), email, password, name);
}
}
// Service becomes thin orchestrator
@Component
public class RegisterUseCase {
public AuthResponse execute(RegisterRequest request) {
// Delegation, not logic
var email = new Email(request.email());
if (repository.existsByEmail(email)) {
throw new EmailAlreadyExistsException(request.email());
}
var user = User.register(
email,
Password.create(request.password(), encoder),
request.name()
);
repository.save(user);
// ...
}
}
Migration Steps:
- Create value objects (Email, Password) alongside existing code
- Extract validation into value object constructors
- Move business rules from service to entity methods
- Update tests to verify domain logic directly
- Gradually remove validation from service layer
Part VII: Common Pitfalls
1. Anemic Domain Services
Problem: All logic in UserService, User is just getters/setters.
// ❌ Wrong: Entity is just data
public class User {
private String email;
private String password;
// ... getters and setters only
}
@Service
public class UserService {
public void validateEmail(String email) { ... }
public void checkPasswordStrength(String password) { ... }
public void ensureEmailUnique(String email) { ... }
// 200 lines of business logic here
}
Fix: Move business rules into User.java.
// ✅ Right: Entity protects its own invariants
public class User {
private final Email email; // Validates in constructor
private Password password; // Validates on change
public void changePassword(Password newPassword) {
// Business rule: password history check
if (isRecentPassword(newPassword)) {
throw new InvalidPasswordException("Cannot reuse password");
}
this.password = newPassword;
}
}
2. God Aggregates
Problem: Order contains User, Product, Payment, Shipping.
// ❌ Wrong: One massive aggregate
public class Order {
private User user; // Should be UserId
private List<Product> products; // Should be OrderItem
private Payment payment; // Should be separate aggregate
private Shipping shipping; // Should be separate aggregate
}
Fix: Reference by ID, separate aggregates.
// ✅ Right: Order references other aggregates by ID
public class Order {
private OrderId id;
private UserId userId; // Reference, not embedded
private List<OrderItem> items; // Part of Order aggregate
private Money total;
private OrderStatus status;
}
public class Payment {
private PaymentId id;
private OrderId orderId; // Reference back to Order
private Money amount;
private PaymentStatus status;
}
Rule: Aggregate boundary = consistency boundary. Only group what MUST be consistent together.
3. Over-Layering
Problem: 7 classes to create a user.
UserController
↓
UserService
↓
UserUseCase
↓
UserDomainService
↓
UserFactory
↓
User
↓
UserRepository
Fix: Use fewer abstractions for simple operations.
AuthController
↓
RegisterUseCase (orchestrates)
↓
User (business logic)
↓
UserRepository (port)
4. Wrong Abstractions
Problem: Creating aggregates for single entities with no invariants.
// ❌ Wrong: Unnecessary aggregate for simple entity
public class StatusAggregate {
private StatusId id; // Unnecessary
private StatusName name;
private List<DomainEvent> events; // Overkill
}
// ✅ Right: Simple enum
public enum Status { ACTIVE, INACTIVE, PENDING }
Decision Tree:
Does this entity have invariants?
↓ No → Simple entity, no aggregate needed
↓ Yes
Are invariants local to this entity?
↓ Yes → Single entity aggregate
↓ No → Multi-entity aggregate
Part VIII: Beyond the Dogma - When Simplicity Wins
The Anti-Pattern Gallery
While DDD patterns are powerful, misapplication causes damage:
| Pattern | Proper Use | Misuse |
|---|---|---|
| Aggregates | Consistency boundaries across multiple entities | Single entities with trivial constraints |
| Bounded Contexts | Multiple teams, different ubiquitous languages | Small team, unified domain |
| Domain Events | Cross-aggregate communication, eventual consistency | Simple CRUD notifications |
| CQRS (Command Query Responsibility Segregation) | Extreme read/write ratio differences | Standard CRUD with no performance issues |
| Value Objects | Complex validation, type safety | Wrappers around primitives with no behavior |
Real-World Simplifications
We skipped CQRS because:
- Our read/write ratio was 70/30 (not extreme)
- Reports were simple SQL queries
- Adding complexity wasn’t justified
We skipped Event Sourcing because:
- Our audit requirements were met by database auditing
- Replay scenarios were rare debugging tools
- The complexity cost exceeded the benefit
We used enums instead of entities for:
- Status codes (ACTIVE, INACTIVE)
- Configuration flags
- Simple reference data
// ✅ Pragmatic: Enum for simple status
public enum OrderStatus {
PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}
// ❌ Overkill: Entity for status with no behavior
@Entity
public class OrderStatusEntity {
@Id private Long id;
private String code;
private String name;
}
When to Simplify Back
You covered “when not to use DDD” but knowing when to remove DDD is equally important:
Consider simplifying when:
- Aggregates have only 1-2 methods with no complex rules
- Use cases do nothing but call
repository.save() - You spend more time maintaining layers than adding features
- Team velocity drops after DDD adoption
- Simple operations require touching 5+ files
Signs you’ve over-engineered:
// ❌ Warning: This aggregate protects nothing
public class StatusAggregate {
private StatusId id;
private StatusName name;
public void activate() { this.status = Status.ACTIVE; }
public void deactivate() { this.status = Status.INACTIVE; }
// No invariants, no rules - just state changes
}
// ✅ Simplify: Use an enum or primitive
public enum Status { ACTIVE, INACTIVE }
Migration path back:
- Consolidate thin use cases into services
- Remove unnecessary value object wrappers
- Simplify aggregate boundaries
- Document why you simplified (for future you)
Remember: Architecture serves the code, not the other way around.
Part IX: Java 25 Features in Practice
Records for Value Objects
Immutable, with built-in equals/hashCode/toString:
public record Email(String value) {
public Email {
// Compact constructor validates before object creation
if (value == null || !isValid(value)) {
throw new InvalidEmailException(value);
}
}
private static boolean isValid(String email) {
return email.matches("^[A-Za-z0-9+_.-]+@(.+)$");
}
}
// Usage - accessor method, not getter
final var email = new Email("john@example.com");
System.out.println(email.value()); // Not email.getValue()
Sealed Classes for Type Safety
Control inheritance and enable exhaustive pattern matching:
public sealed interface PaymentMethod
permits CreditCard, PayPal, BankTransfer {}
public final record CreditCard(String number, String expiry)
implements PaymentMethod {}
public final record PayPal(String email)
implements PaymentMethod {}
public final record BankTransfer(String accountNumber, String routingNumber)
implements PaymentMethod {}
// Exhaustive switch - compiler ensures all cases covered
public String process(PaymentMethod method) {
return switch (method) {
case CreditCard cc ->
"Processing card: " + maskCard(cc.number());
case PayPal pp ->
"Processing PayPal: " + pp.email();
case BankTransfer bt ->
"Processing transfer: " + bt.accountNumber();
}; // No default needed - compiler knows all cases
}
Pattern Matching with instanceof
Cleaner type checking without casting:
// Before Java 16
if (obj instanceof User) {
User user = (User) obj; // Redundant cast
process(user);
}
// Java 16+ - pattern matching
if (obj instanceof User user) {
process(user); // user automatically available and cast
}
Text Blocks for SQL
Multi-line strings without concatenation:
private static final String FIND_ACTIVE_USERS = """
SELECT u.id, u.email, u.name, u.role, u.created_at
FROM users u
WHERE u.banned = false
AND u.deleted = false
ORDER BY u.created_at DESC
""";
Virtual Threads for I/O
Lightweight concurrency for external calls:
// Java 21+ Virtual Threads - concurrent repository queries
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = userIds.stream()
.map(id -> CompletableFuture.supplyAsync(
() -> userRepository.findById(id).orElseThrow(),
executor))
.toList();
return futures.stream()
.map(CompletableFuture::join)
.toList();
}
Flexible Constructor Bodies (Java 25)
Status: Finalized in Java 25 (JEP 513). Previously previewed in Java 22-24.
Validate before field assignment:
public class User {
private final Email email;
public User(String emailValue) {
// Validate BEFORE assignment
if (emailValue == null) {
throw new IllegalArgumentException("Email required");
}
// Now assign
this.email = new Email(emailValue);
}
}
Part X: Project Structure
Recommended Package Layout
src/main/java/com/example/api/
├── domain/ # Layer 1: Domain (heart of system)
│ ├── user/ # One package per aggregate
│ │ ├── User.java # Aggregate root
│ │ ├── UserId.java # Value object
│ │ ├── Email.java # Value object
│ │ ├── Password.java # Value object
│ │ ├── UserRepository.java # Repository port (interface)
│ │ └── exception/ # Domain exceptions
│ │ ├── InvalidEmailException.java
│ │ ├── EmailAlreadyExistsException.java
│ │ └── UserNotFoundException.java
│ └── shared/ # Shared kernel (if needed)
│ └── Money.java
│
├── application/ # Layer 2: Application
│ ├── auth/
│ │ ├── RegisterUseCase.java
│ │ ├── LoginUseCase.java
│ │ └── dto/
│ │ ├── RegisterRequest.java
│ │ └── AuthResponse.java
│ └── exception/
│ └── ApplicationException.java
│
├── infrastructure/ # Layer 3: Infrastructure
│ ├── persistence/
│ │ ├── UserJpaEntity.java
│ │ ├── UserJpaRepository.java # Spring Data
│ │ ├── UserDomainMapper.java # Domain <-> Entity mapping
│ │ └── JpaUserRepositoryAdapter.java
│ ├── security/
│ │ ├── JwtTokenProvider.java
│ │ └── BCryptPasswordEncoder.java
│ └── config/
│ └── DatabaseConfig.java
│
└── presentation/ # Layer 4: Presentation
├── auth/
│ ├── AuthController.java
│ └── dto/
│ └── RegisterRequestDto.java
└── exception/
├── GlobalExceptionHandler.java
└── ApiError.java
Epilogue: The Architecture of Thought
We’ve been taught that good architecture is about rigid structure - walls that never move, foundations that never shift. But the best buildings aren’t monuments to immutability. They’re adaptable spaces that serve changing needs.
Pragmatic DDD gives you that adaptability. It provides the load-bearing walls (domain logic) while letting you repaint the facade (infrastructure) or reconfigure the plumbing (application) without touching what matters.
The future of software architecture isn’t about following every pattern. It’s about knowing which patterns to apply - and having the wisdom to skip the rest.
That future is already here. The tools are in your IDE. The only question is: will you build a cathedral for your vanity, or a house that people can actually live in?
Next Steps
Week 1: Pick ONE aggregate in your current project - start small Week 2-3: Extract value objects for email, phone, or currency fields Month 1: Move one business rule from service to entity, measure test speed Month 2: Evaluate impact and decide which areas deserve DDD patterns Ongoing: Review quarterly - simplify where DDD adds no value
Start Small:
- Pick ONE aggregate in your current project
- Extract value objects for email, phone, or currency
- Move one business rule from service to entity
Measure Impact:
- Track test execution time before/after (domain tests should be significantly faster than integration tests)
- Count lines of code in domain vs service layers
- Time how long new features take to implement
- Survey new developers: how long to understand the codebase?
Iterate:
- Apply DDD patterns where complexity justifies it
- Use simpler patterns for straightforward CRUD
- Review and refactor as requirements evolve
- Simplify back when patterns no longer serve you
Key Takeaways
-
Domain is King: Business logic belongs in the domain layer, period.
-
Dependencies Flow Inward: Domain has no dependencies; infrastructure depends on domain.
-
Ports & Adapters: Domain defines interfaces; infrastructure implements them.
-
Value Objects Over Primitives:
Emailis better thanStringeverywhere. -
One Use Case, One Class:
RegisterUseCasedoes one thing well. -
Pragmatism Over Purity: Don’t create a bounded context for a lookup table.
-
Test the Domain: Domain tests should run in milliseconds without Spring context.
-
Start Simple, Evolve: Use transaction scripts for prototypes, DDD for mature domains.
References
- Book: “Domain-Driven Design” by Eric Evans (the original)
- Book: “Implementing Domain-Driven Design” by Vaughn Vernon (practical)
- Book: “Clean Architecture” by Robert C. Martin (layered architecture)
- Pattern: Hexagonal Architecture / Ports and Adapters
- Java: Modern Java features guide (Java 17-25)
Written for developers who’ve seen “perfect” architectures collapse under their own weight. Sometimes the best pattern is the one you don’t use.