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)
@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"));
}
}Error Handling Strategy
Exception Hierarchy by Layer
Domain
Broken business rules
DomainExceptionbase domain exceptionInvalidEmailExceptionbusiness rule violationEmailAlreadyExistsExceptionInsufficientFundsException
Application
Use case failures
ApplicationExceptionAuthenticationFailedException
Presentation
Maps both to HTTP responses
GlobalExceptionHandlerApiError
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()));
}
}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
Common Pitfalls
1. Anemic Domain Services
Problem: All logic in UserService, User is just getters/setters.
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.
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.
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.
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
UserControllerUserServiceUserUseCaseUserDomainServiceUserFactoryUserUserRepository
Fix4 classes
AuthControllerRegisterUseCaseorchestratesUserbusiness logicUserRepositoryport
4. Wrong Abstractions
Problem: Creating aggregates for single entities with no invariants.
public class StatusAggregate {
private StatusId id; // Unnecessary
private StatusName name;
private List<DomainEvent> events; // Overkill
}public enum Status { ACTIVE, INACTIVE, PENDING }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
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.