Skip to content
Michał Artur Marciniak
Go back

Pragmatic DDD: Architecture Without Dogma

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:

ContextUse DDD?WhyExample
Complex business rules, multiple paths through workflowsYesBusiness logic centralization prevents scatteringE-commerce checkout with discounts, taxes, inventory
Long-term project (2+ years), evolving requirementsYesBoundaries protect against future changesEnterprise billing system with changing regulations
Multiple teams, changing developersYesClear structure reduces onboarding frictionBanking platform with 5+ teams contributing
Simple CRUD, no complex rulesNoTransaction scripts are faster to write and maintainBlog post CMS - create, read, update, delete only
Rapid prototype/MVPNoYAGNI (You Are not Gonna Need It) - validate firstStartup landing page with contact form
Team unfamiliar with DDDNoWrong abstractions are worse than no abstractionsJunior 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:


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:

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:

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:

  1. Create value objects (Email, Password) alongside existing code
  2. Extract validation into value object constructors
  3. Move business rules from service to entity methods
  4. Update tests to verify domain logic directly
  5. 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

While DDD patterns are powerful, misapplication causes damage:

PatternProper UseMisuse
AggregatesConsistency boundaries across multiple entitiesSingle entities with trivial constraints
Bounded ContextsMultiple teams, different ubiquitous languagesSmall team, unified domain
Domain EventsCross-aggregate communication, eventual consistencySimple CRUD notifications
CQRS (Command Query Responsibility Segregation)Extreme read/write ratio differencesStandard CRUD with no performance issues
Value ObjectsComplex validation, type safetyWrappers around primitives with no behavior

Real-World Simplifications

We skipped CQRS because:

We skipped Event Sourcing because:

We used enums instead of entities for:

// ✅ 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:

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:

  1. Consolidate thin use cases into services
  2. Remove unnecessary value object wrappers
  3. Simplify aggregate boundaries
  4. 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

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:

  1. Pick ONE aggregate in your current project
  2. Extract value objects for email, phone, or currency
  3. Move one business rule from service to entity

Measure Impact:

Iterate:


Key Takeaways

  1. Domain is King: Business logic belongs in the domain layer, period.

  2. Dependencies Flow Inward: Domain has no dependencies; infrastructure depends on domain.

  3. Ports & Adapters: Domain defines interfaces; infrastructure implements them.

  4. Value Objects Over Primitives: Email is better than String everywhere.

  5. One Use Case, One Class: RegisterUseCase does one thing well.

  6. Pragmatism Over Purity: Don’t create a bounded context for a lookup table.

  7. Test the Domain: Domain tests should run in milliseconds without Spring context.

  8. Start Simple, Evolve: Use transaction scripts for prototypes, DDD for mature domains.


References


Written for developers who’ve seen “perfect” architectures collapse under their own weight. Sometimes the best pattern is the one you don’t use.


Share this post on:

Next Post
JVM: The Silent Revolution