Skip to content
Back
Software design

Pragmatic DDD in Practice: Testing, Errors, Migration and Pitfalls

Fast domain tests and a few full-stack ones, exceptions that each layer translates, moving a service-heavy codebase to a rich domain step by step, and four common pitfalls.

Michał Artur Marciniak

5 min read

On this page
  1. Testing Strategy
  2. Error Handling Strategy
  3. Migration Strategy
  4. Common Pitfalls
  5. Epilogue: The Architecture of Thought
  6. Next Steps
  7. Key Takeaways
  8. References

Part 2 built the four layers. This last part covers what happens around them in a real project: how to test each layer, how errors travel through them, how to move an existing service-heavy codebase towards a rich domain, and the mistakes that are easy to make on the way.

Testing Strategy

Domain Layer Tests (Fast, No Context)

Java
@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)

Java
@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)

Java
@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"));
    }
}

Error Handling Strategy

Exception Hierarchy by Layer

Domain

Broken business rules

  • DomainExceptionbase domain exception
  • InvalidEmailExceptionbusiness rule violation
  • EmailAlreadyExistsException
  • InsufficientFundsException

Application

Use case failures

  • ApplicationException
  • AuthenticationFailedException

Presentation

Maps both to HTTP responses

  • GlobalExceptionHandler
  • ApiError
Each layer keeps its exceptions in its own exception/ package. The domain and the application only throw; the presentation layer is the one place that turns an exception into an HTTP status.

Domain Exceptions (Business Rules)

Java
// 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

Java
// 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()));
    }
}

Migration Strategy

From Transaction Script to Rich Domain

Before (Transaction Script):

Java
@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):

Java
// 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

Common Pitfalls

1. Anemic Domain Services

Problem: All logic in UserService, User is just getters/setters.

Entity is just dataJava
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.

Entity protects its own invariantsJava
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.

One massive aggregateJava
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.

Order references other aggregates by IDJava
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.

Fix: Use fewer abstractions for simple operations.

Problem7 classes

  1. UserController
  2. UserService
  3. UserUseCase
  4. UserDomainService
  5. UserFactory
  6. User
  7. UserRepository

Fix4 classes

  1. AuthController
  2. RegisterUseCaseorchestrates
  3. Userbusiness logic
  4. UserRepositoryport

4. Wrong Abstractions

Problem: Creating aggregates for single entities with no invariants.

Unnecessary aggregate for simple entityJava
public class StatusAggregate {
    private StatusId id;  // Unnecessary
    private StatusName name;
    private List<DomainEvent> events;  // Overkill
}
Simple enumJava
public enum Status { ACTIVE, INACTIVE, PENDING }
Does this entity need an aggregate?Flowchart with 5 nodes and 4 connectionsNoYesYesNoDoes the entity have invariants?Does the entity have invariants?Simple entity, no aggregateSimple entity, no aggregateAre they local to this entity?Are they local to this entity?Single-entity aggregateSingle-entity aggregateMulti-entity aggregateMulti-entity aggregate

Does the entity have invariants?

  • NoSimple entity, no aggregate
  • YesAre they local to this entity?

Are they local to this entity?

  • YesSingle-entity aggregate
  • NoMulti-entity aggregate
Start from the invariants. An entity without them needs no aggregate at all, and only invariants that span several entities justify a larger one.

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:

  • 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

  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

  • 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.