Architecture | Domain-Driven Design in Java Spring Boot

Most Spring Boot projects I’ve walked into have the same structural problem: JPA annotations scattered across domain objects, business logic living in services that know too much about persistence, and a codebase that makes it hard to answer the question “what does this system actually do?”. Domain-Driven Design isn’t a silver bullet — it adds real complexity — but on systems with genuine domain complexity (like the DWP Digital benefit platform or a Betfair trading strategy engine), it produces code that’s far easier to evolve and reason about.

Aggregates and Aggregate Roots

An aggregate is a cluster of domain objects treated as a single unit for the purposes of data changes. Every aggregate has an aggregate root — the only entry point for external interaction. The classic rule: reference other aggregates by ID, not by object reference.

public class BenefitClaim {  // aggregate root

    private final ClaimId id;
    private ClaimStatus status;
    private final ClaimantId claimantId;  // reference to Claimant aggregate by ID
    private final List<Assessment> assessments = new ArrayList<>();
    private final List<DomainEvent> events = new ArrayList<>();

    public static BenefitClaim submit(ClaimantId claimantId, ClaimType type) {
        BenefitClaim claim = new BenefitClaim(ClaimId.generate(), claimantId, type);
        claim.events.add(new ClaimSubmitted(claim.id, claimantId, type, Instant.now()));
        return claim;
    }

    public void approve(AssessorId assessorId, String reason) {
        if (this.status != ClaimStatus.PENDING) {
            throw new IllegalStateException("Only PENDING claims can be approved");
        }
        this.status = ClaimStatus.APPROVED;
        this.events.add(new ClaimApproved(this.id, assessorId, reason, Instant.now()));
    }

    public List<DomainEvent> pullEvents() {
        List<DomainEvent> pending = List.copyOf(events);
        events.clear();
        return pending;
    }
}

Business rules live on the aggregate. approve() enforces the state machine. The service layer calls claim.approve() and handles persistence and event publication — it doesn’t contain the rule itself.

Value Objects

A value object has no identity — it’s defined entirely by its attributes. Coordinates, money amounts, email addresses: these should be value objects rather than primitives.

public record Money(BigDecimal amount, Currency currency) {

    public Money {
        Objects.requireNonNull(amount);
        Objects.requireNonNull(currency);
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Money amount cannot be negative");
        }
        amount = amount.setScale(2, RoundingMode.HALF_UP);
    }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Cannot add different currencies");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

Using Java records for value objects is clean and idiomatic in Java 16+. The compact constructor provides validation. Equality is structural by default.

Domain Events

Domain events record what happened, and are the mechanism for integration between bounded contexts:

public sealed interface DomainEvent permits ClaimSubmitted, ClaimApproved, ClaimRejected {
    ClaimId claimId();
    Instant occurredAt();
}

public record ClaimApproved(
    ClaimId claimId,
    AssessorId assessorId,
    String reason,
    Instant occurredAt
) implements DomainEvent {}

The application service publishes events after saving the aggregate:

@Service
@Transactional
public class ClaimApplicationService {

    private final ClaimRepository repository;
    private final ApplicationEventPublisher eventPublisher;

    public void approveClaim(ClaimId claimId, AssessorId assessorId, String reason) {
        BenefitClaim claim = repository.findById(claimId)
            .orElseThrow(() -> new ClaimNotFoundException(claimId));

        claim.approve(assessorId, reason);

        repository.save(claim);

        // Publish domain events after commit
        claim.pullEvents().forEach(eventPublisher::publishEvent);
    }
}

Other services can listen to ClaimApproved events via @EventListener (same JVM) or via Kafka for cross-service integration.

Keeping JPA Out of the Domain Model

The most important structural decision in DDD with Spring Boot: don’t put @Entity, @Column, or @OneToMany on your domain objects. JPA concerns (lazy loading, proxy objects, persistence state) are infrastructure concerns. Mix them into your domain model and you couple your business logic to your ORM.

Instead, create separate persistence models:

// Domain object — pure Java, no JPA annotations
public class BenefitClaim {
    private final ClaimId id;
    private ClaimStatus status;
    // ...
}

// JPA entity — infrastructure layer
@Entity
@Table(name = "benefit_claims")
class ClaimJpaEntity {
    @Id
    private String id;
    @Enumerated(EnumType.STRING)
    private ClaimStatus status;
    // ...
}

// Repository implementation — maps between the two
@Repository
class JpaClaimRepository implements ClaimRepository {

    private final ClaimJpaRepository jpa;

    @Override
    public Optional<BenefitClaim> findById(ClaimId id) {
        return jpa.findById(id.value())
            .map(this::toDomain);
    }

    @Override
    public void save(BenefitClaim claim) {
        jpa.save(toEntity(claim));
    }

    private BenefitClaim toDomain(ClaimJpaEntity entity) {
        return BenefitClaim.reconstitute(
            new ClaimId(entity.getId()),
            entity.getStatus()
            // ...
        );
    }
}

Yes, this is more code. The payoff is a domain model that’s testable without a Spring context, portable to a different persistence technology, and readable without knowing JPA.

Bounded Contexts

A bounded context is a boundary within which a particular domain model applies. In a large system, different teams maintain different models of the same concept. In the DWP system, “Claimant” means something different to the Identity service, the Eligibility service, and the Payment service. Don’t try to create a single unified model — create three context-appropriate models and translate between them at context boundaries.

// Eligibility bounded context
public class EligibleClaimant {
    private final NiNumber niNumber;
    private final int age;
    private final EmploymentStatus employmentStatus;
    // eligibility-specific domain logic
}

// Payment bounded context
public class PaymentRecipient {
    private final NiNumber niNumber;
    private final BankAccount bankAccount;
    // payment-specific domain logic
}

Translation between contexts happens in an anti-corruption layer — a class whose sole job is to translate between models without polluting either.

ProTips

If you’re looking for a Java contractor who knows this space inside out, get in touch.