Skip to content
Back
Software design

The Four Layers of Pragmatic DDD: Code, Packages and Java 25

Which way dependencies point, how one request crosses the layers, what belongs in each of the four layers, how they map to packages, and the Java 25 features that keep the domain model short.

Michał Artur Marciniak

4 min read

On this page
  1. Anatomy of a Clean Architecture
  2. Core Building Blocks
  3. Project Structure
  4. Java 25 Features in Practice

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:

Dependencies point inwardFlowchart with 4 nodes and 4 connectionscallsusesimplements portsimplements portsPresentationPresentationApplicationApplicationDomainDomainInfrastructureInfrastructure
  1. Presentation → Applicationcalls
  2. Application → Domainuses
  3. Infrastructure → Domainimplements ports
  4. Infrastructure → Applicationimplements ports
Every arrow points towards the domain. The domain depends on nothing; infrastructure implements the interfaces (ports) that the domain and the application define, so a database or framework can be swapped without touching business rules.

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

A registration request through the layersSequence with 4 participants and 9 messagesPresentationPresentationApplicationApplicationDomainDomainInfrastructureInfrastructureValidates the DTORegisterUseCase.execute()User.register()Enforces invariantsNew userUserRepository.save()TokenProvider.generateToken()TokenAuthResponse
  1. PresentationValidates the DTO
  2. Presentation → ApplicationRegisterUseCase.execute()
  3. Application → DomainUser.register()
  4. DomainEnforces invariants
  5. Domain → ApplicationNew user
  6. Application → InfrastructureUserRepository.save()
  7. Application → InfrastructureTokenProvider.generateToken()
  8. Infrastructure → ApplicationToken
  9. Application → PresentationAuthResponse
The controller only validates the request, the use case only orchestrates, and the business rules run inside User.register(). Persistence and tokens go through ports that infrastructure implements.

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:

Java
// 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.java contains 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:

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

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

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

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

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

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

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

src/main/java/com/example/api/

Layer 1

Domain

The heart of the system

  • user/one package per aggregate
    • User.javaaggregate root
    • UserId.javavalue object
    • Email.javavalue object
    • Password.javavalue object
    • UserRepository.javarepository port (interface)
    • exception/domain exceptions
      • InvalidEmailException.java
      • EmailAlreadyExistsException.java
      • UserNotFoundException.java
  • shared/shared kernel, if needed
    • Money.java

Layer 2

Application

Use cases that orchestrate the domain

  • auth/
    • RegisterUseCase.java
    • LoginUseCase.java
    • dto/
      • RegisterRequest.java
      • AuthResponse.java
  • exception/
    • ApplicationException.java

Layer 3

Infrastructure

Adapters that implement the ports

  • persistence/
    • UserJpaEntity.java
    • UserJpaRepository.javaSpring Data
    • UserDomainMapper.javadomain ↔ entity mapping
    • JpaUserRepositoryAdapter.java
  • security/
    • JwtTokenProvider.java
    • BCryptPasswordEncoder.java
  • config/
    • DatabaseConfig.java

Layer 4

Presentation

HTTP in and out

  • auth/
    • AuthController.java
    • dto/
      • RegisterRequestDto.java
  • exception/
    • GlobalExceptionHandler.java
    • ApiError.java

Java 25 Features in Practice

Records for Value Objects

Immutable, with built-in equals/hashCode/toString:

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

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

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

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

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