Part 1 asked whether DDD is worth it at all. This part assumes it is and walks through the architecture: which way dependencies point, how a request travels, what each of the four layers is responsible for, and how the layers map to packages.
Anatomy of a Clean Architecture
The Dependency Compass
Your code doesn’t just “run.” It obeys directional gravity:
- Presentation → Applicationcalls
- Application → Domainuses
- Infrastructure → Domainimplements ports
- Infrastructure → Applicationimplements ports
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
- PresentationValidates the DTO
- Presentation → ApplicationRegisterUseCase.execute()
- Application → DomainUser.register()
- DomainEnforces invariants
- Domain → ApplicationNew user
- Application → InfrastructureUserRepository.save()
- Application → InfrastructureTokenProvider.generateToken()
- Infrastructure → ApplicationToken
- Application → PresentationAuthResponse
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.
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);
}
}Project Structure
Recommended Package Layout
src/main/java/com/example/api/
Layer 1
Domain
The heart of the system
user/one package per aggregateUser.javaaggregate rootUserId.javavalue objectEmail.javavalue objectPassword.javavalue objectUserRepository.javarepository port (interface)exception/domain exceptionsInvalidEmailException.javaEmailAlreadyExistsException.javaUserNotFoundException.java
shared/shared kernel, if neededMoney.java
Layer 2
Application
Use cases that orchestrate the domain
auth/RegisterUseCase.javaLoginUseCase.javadto/RegisterRequest.javaAuthResponse.java
exception/ApplicationException.java
Layer 3
Infrastructure
Adapters that implement the ports
persistence/UserJpaEntity.javaUserJpaRepository.javaSpring DataUserDomainMapper.javadomain ↔ entity mappingJpaUserRepositoryAdapter.java
security/JwtTokenProvider.javaBCryptPasswordEncoder.java
config/DatabaseConfig.java
Layer 4
Presentation
HTTP in and out
auth/AuthController.javadto/RegisterRequestDto.java
exception/GlobalExceptionHandler.javaApiError.java
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);
}
}