Why a Repository Should Never Be a DAO: DDD Best Practices in Spring Boot

The article explains how a Repository differs from a DAO by focusing on aggregates instead of tables, shows proper interface design, PO‑to‑aggregate mapping, read/write separation, CQRS basics, and JPA pitfalls, providing concrete Java code examples for Spring Boot projects.

Tinker Programmer
Tinker Programmer
Tinker Programmer
Why a Repository Should Never Be a DAO: DDD Best Practices in Spring Boot

Many teams that adopt Domain‑Driven Design (DDD) end up turning the Repository layer into a thin alias of a DAO, exposing dozens of CRUD‑style methods such as findById, save, update, delete, and a long list of findByXxxAndYyy queries. The author shows a real PaySlipRepository that contains 17 findBy… methods returning persistence objects (PO) or Map, which is indistinguishable from a MyBatis mapper.

Repository vs. DAO

The fundamental difference is the unit of thought:

DAO is table‑oriented; it cares about how a single table is created, read, updated, or deleted. One DAO maps to one table and returns PO or DTO. Method names revolve around column names.

Repository is aggregate‑oriented; it cares about how an entire aggregate is persisted or retrieved. One Repository maps to one aggregate root and returns a fully built domain object. Method names reflect business intent.

These differences manifest in code:

// DAO style (table‑oriented)
public interface PaySlipDao {
    PaySlipPO findById(String id);
    List<PaySlipPO> findByEmployeeIdAndStatus(String empId, String status);
    void updateStatus(String id, String status);
    void insert(PaySlipPO po);
    BigDecimal sumGrossByCycle(String cycle);
}

// Repository style (aggregate‑oriented)
public interface PaySlipRepository {
    Optional<PaySlip> findById(PaySlipId id);
    Optional<PaySlip> findByEmployeeAndCycle(EmployeeId empId, PayCycle cycle);
    void save(PaySlip paySlip); // insert or update is decided internally
}

Key contrasts:

Return type : DAO returns PO; Repository returns the aggregate root. A Repository never returns a half‑filled object.

Method semantics : DAO’s updateStatus changes a column directly, bypassing domain rules. Repository only offers save; the caller must load the aggregate, invoke its business method, then save, ensuring invariants are respected.

Method count : DAO proliferates methods for every field combination; Repository keeps the interface small, exposing only the operations the aggregate truly needs.

Not every database query belongs in a Repository. Complex list or report queries should be placed elsewhere.

Layering

The Repository interface lives in the domain layer, while its implementation resides in the infrastructure layer. This follows the Dependency Inversion Principle: application services depend only on the domain interface and are unaware of whether the underlying persistence uses JPA, MyBatis, MySQL, or MongoDB.

com.company.payroll/
├── domain/
│   ├── PaySlip.java               // aggregate root
│   └── PaySlipRepository.java     // domain interface
├── application/
│   └── SalaryCalculationAppService.java
└── infrastructure/
    ├── persistence/PaySlipPO.java          // JPA entity (PO)
    ├── persistence/PaySlipJpaRepository.java // Spring Data JPA interface
    └── persistence/PaySlipRepositoryImpl.java // implementation

The domain interface should expose only aggregate‑level operations:

public interface PaySlipRepository {
    Optional<PaySlip> findById(PaySlipId id);
    Optional<PaySlip> findByEmployeeAndCycle(EmployeeId employeeId, PayCycle cycle);
    void save(PaySlip paySlip);
}

Parameters are value objects ( PaySlipId, EmployeeId) rather than raw strings, so type mismatches are caught at compile time.

Mapping PO ↔ Aggregate

The conversion between persistence objects and domain aggregates happens inside the implementation, never in the caller. The PO is a pure table mapping with JPA annotations; the aggregate contains business logic and no persistence concerns.

@Entity
@Table(name = "pay_slip")
public class PaySlipPO {
    @Id
    private String id;
    @Column(name = "employee_id", nullable = false)
    private String employeeId;
    @Column(name = "pay_cycle", nullable = false)
    private String payCycle; // YYYY‑MM
    @Column(name = "status", nullable = false)
    private String status;
    @Column(name = "gross_amount")
    private BigDecimal grossAmount;
    @Column(name = "net_amount")
    private BigDecimal netAmount;
    @Version
    private Long version; // optimistic lock, PO only
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "pay_slip_id")
    private List<PaySlipLineItemPO> lineItems = new ArrayList<>();

    public static PaySlipPO from(PaySlip paySlip) { /* explicit field mapping */ }
    public PaySlip toDomain() { /* rebuild aggregate */ }
}

Three practical mapping tips:

Do not use BeanUtils.copyProperties; explicit from / toDomain mappings make every field visible.

Keep JPA annotations on the PO, never on the aggregate, to avoid coupling the domain layer to a specific ORM.

Place optimistic‑lock fields ( @Version) on the PO only; the aggregate receives concurrency exceptions as domain‑level ConcurrentModificationException.

Separating Read Queries

Queries that only need data for display, reporting, or search should not be forced into the Repository. The author proposes a parallel QueryService that returns DTOs:

public interface PaySlipQueryService {
    /** List‑page query – returns DTOs, no aggregate involved */
    Page<PaySlipListItem> query(PaySlipQuery query, Pageable pageable);
    PaySlipMonthlySummary summarize(PayCycle cycle);
}

public class PaySlipListItem { // pure DTO
    private String paySlipId;
    private String employeeName; // from another Bounded Context
    private String departmentName;
    private Money netSalary;
    private PaySlipStatus status;
    private LocalDate issuedAt;
}

The implementation can use plain SQL, MyBatis, or Elasticsearch, and lives in the infrastructure layer, following the same dependency‑inversion pattern as the Repository.

This separation is a lightweight form of CQRS: write operations go through the Repository (returning aggregates), while read‑only scenarios use the QueryService (returning DTOs). When read‑side load grows, the QueryService can be swapped for an Elasticsearch implementation without touching the domain layer.

Lazy‑Loading Pitfalls

Returning a JPA proxy from a Repository leads to LazyInitializationException once the transaction ends. Two approaches exist:

Solution 1 (recommended) : Load all required associations inside the Repository using fetch join or @EntityGraph, so the returned aggregate is fully initialized.

Solution 2 : Enlarge the read transaction with @Transactional(readOnly = true) in the application service, but this spreads transaction boundaries and is discouraged.

// fetch‑join example – returns a fully loaded aggregate
public Optional<PaySlip> findById(PaySlipId id) {
    List<PaySlipPO> results = em.createQuery(
        "SELECT ps FROM PaySlipPO ps LEFT JOIN FETCH ps.lineItems WHERE ps.id = :id",
        PaySlipPO.class)
        .setParameter("id", id.getValue())
        .getResultList();
    if (results.isEmpty()) return Optional.empty();
    return Optional.of(results.get(0).toDomain());
}

Correct Use of Spring Data JPA

While Spring Data JPA’s JpaRepository provides many convenience methods, exposing it directly as a Repository would re‑introduce DAO‑like methods ( findAll, deleteAll, etc.). The proper pattern is to hide the Spring Data interface inside the implementation and expose only the domain‑specific methods.

// infrastructure‑only Spring Data interface
interface PaySlipJpaRepository extends JpaRepository<PaySlipPO, String>, JpaSpecificationExecutor<PaySlipPO> {
    @EntityGraph(attributePaths = "lineItems")
    Optional<PaySlipPO> findWithLineItemsById(String id);
    @EntityGraph(attributePaths = "lineItems")
    Optional<PaySlipPO> findByEmployeeIdAndPayCycle(String employeeId, String payCycle);
}

// domain Repository implementation
@Repository
public class PaySlipRepositoryImpl implements PaySlipRepository {
    private final PaySlipJpaRepository jpa;
    public PaySlipRepositoryImpl(PaySlipJpaRepository jpa) { this.jpa = jpa; }
    @Override
    public Optional<PaySlip> findById(PaySlipId id) {
        return jpa.findWithLineItemsById(id.getValue()).map(PaySlipPO::toDomain);
    }
    @Override
    public Optional<PaySlip> findByEmployeeAndCycle(EmployeeId employeeId, PayCycle cycle) {
        return jpa.findByEmployeeIdAndPayCycle(employeeId.getValue(), cycle.getValue())
                  .map(PaySlipPO::toDomain);
    }
    @Override
    @Transactional
    public void save(PaySlip paySlip) {
        PaySlipPO po = PaySlipPO.from(paySlip);
        jpa.save(po);
        publishEvents(paySlip);
    }
    private void publishEvents(PaySlip paySlip) {
        paySlip.pullDomainEvents().forEach(eventPublisher::publishEvent);
    }
}

By keeping the Spring Data interface private, the domain layer sees only the intentional aggregate operations and cannot accidentally call generic CRUD methods.

Conclusion

Repository is not a renamed DAO: DAO revolves around tables and PO, Repository around aggregates and domain objects.

Define the interface in the domain layer and implement it in the infrastructure layer to preserve the dependency‑inversion boundary.

Separate PO and aggregate classes; map them explicitly with from / toDomain methods.

Split read‑only queries into a parallel QueryService that returns DTOs, forming a CQRS‑like separation.

Ensure a Repository always returns a fully loaded aggregate (fetch join or EntityGraph) to avoid lazy‑loading traps.

Hide Spring Data JPA interfaces inside the implementation to prevent exposure of unwanted CRUD methods.

The next article will expand the CQRS sketch into a full read/write model, showing how to move heavy list and report queries to a separate data store such as Elasticsearch.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Spring BootDDDCQRSDAORepositoryJPA
Tinker Programmer
Written by

Tinker Programmer

Solving problems with code, sharing practical tech insights, and leveling up together!

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.