Data Access Abstraction: Implementing the Repository Pattern in DDD with Java

Introduction

In Domain-Driven Design (DDD), maintaining a clean separation between domain logic and data persistence is crucial. The Repository Pattern provides a collection-like interface for accessing domain objects while hiding the complexity of underlying data storage mechanisms. It acts as a mediator between the domain and data mapping layers, providing a clean, domain-centric API for data access.

What is the Repository Pattern?

A Repository is a layer that:

  • Provides an abstraction over data storage
  • Offers collection-like semantics for domain objects
  • Encapsulates data access logic and query implementation
  • Preserves the integrity of aggregates
  • Enables domain model purity by keeping persistence concerns separate

Core Concepts

1. Repository Characteristics

  • Collection-oriented interface - behaves like an in-memory collection
  • Aggregate-focused - works with complete aggregates, not individual entities
  • Persistence ignorant - domain objects don't know about persistence
  • Query abstraction - encapsulates complex data queries

2. Repository vs DAO (Data Access Object)

  • DAO is table-centric, works with single entities, closer to database
  • Repository is domain-centric, works with aggregates, more abstract

Implementation Structure

1. Generic Repository Interface

package com.example.ddd.repository;
import java.util.List;
import java.util.Optional;
/**
* Base repository interface with common operations
*/
public interface Repository<T, ID> {
Optional<T> findById(ID id);
List<T> findAll();
T save(T entity);
void delete(T entity);
void deleteById(ID id);
boolean existsById(ID id);
long count();
}

2. Domain-Specific Repository Interface

package com.example.ecommerce.order.repository;
import com.example.ddd.repository.Repository;
import com.example.ecommerce.order.model.Order;
import com.example.ecommerce.order.model.OrderId;
import com.example.ecommerce.customer.model.CustomerId;
import java.time.LocalDateTime;
import java.util.List;
public interface OrderRepository extends Repository<Order, OrderId> {
// Domain-specific queries
List<Order> findByCustomerId(CustomerId customerId);
List<Order> findByStatus(OrderStatus status);
List<Order> findOrdersCreatedBetween(LocalDateTime start, LocalDateTime end);
List<Order> findOrdersWithTotalGreaterThan(Money minTotal);
// Complex business queries
boolean customerHasActiveOrders(CustomerId customerId);
long countOrdersByCustomerAndStatus(CustomerId customerId, OrderStatus status);
// Performance-optimized queries
List<OrderSummary> findOrderSummariesByCustomer(CustomerId customerId);
}

3. Supporting Domain Classes

// Value Objects
public record OrderId(String value) {
public OrderId {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException("Order ID cannot be empty");
}
}
}
public record CustomerId(String value) {
public CustomerId {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException("Customer ID cannot be empty");
}
}
}
// Aggregate Root
public class Order {
private OrderId orderId;
private CustomerId customerId;
private List<OrderLineItem> lineItems;
private OrderStatus status;
private Money totalAmount;
private LocalDateTime createdAt;
// Domain logic and business methods...
public void addLineItem(ProductId productId, Money unitPrice, int quantity) {
// Business logic
}
public void submit() {
// Business rules
this.status = OrderStatus.SUBMITTED;
}
// Getters...
public OrderId getOrderId() { return orderId; }
public CustomerId getCustomerId() { return customerId; }
public OrderStatus getStatus() { return status; }
}

4. JPA Implementation

package com.example.ecommerce.order.repository.jpa;
import com.example.ecommerce.order.model.*;
import com.example.ecommerce.order.repository.OrderRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface JpaOrderRepository extends JpaRepository<Order, Long>, OrderRepository {
// Spring Data JPA will implement these automatically
@Override
default Optional<Order> findById(OrderId orderId) {
return findByOrderId(orderId);
}
Optional<Order> findByOrderId(OrderId orderId);
@Query("SELECT o FROM Order o WHERE o.customerId = :customerId")
List<Order> findByCustomerId(@Param("customerId") CustomerId customerId);
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatus(@Param("status") OrderStatus status);
@Query("SELECT o FROM Order o WHERE o.createdAt BETWEEN :start AND :end")
List<Order> findOrdersCreatedBetween(@Param("start") LocalDateTime start, 
@Param("end") LocalDateTime end);
@Query("SELECT o FROM Order o WHERE o.totalAmount.amount > :minAmount")
List<Order> findOrdersWithTotalGreaterThan(@Param("minAmount") BigDecimal minAmount);
@Query("SELECT COUNT(o) > 0 FROM Order o WHERE o.customerId = :customerId AND o.status IN :activeStatuses")
boolean customerHasActiveOrders(@Param("customerId") CustomerId customerId);
@Query("SELECT new com.example.ecommerce.order.model.OrderSummary(o.orderId, o.totalAmount, o.status, o.createdAt) " +
"FROM Order o WHERE o.customerId = :customerId")
List<OrderSummary> findOrderSummariesByCustomer(@Param("customerId") CustomerId customerId);
// Custom save handling for domain identifiers
@Override
default Order save(Order order) {
if (order.getOrderId() == null) {
// Generate domain ID if needed
order.setOrderId(OrderId.generate());
}
return saveOrder(order);
}
Order saveOrder(Order order);
@Override
default void deleteById(OrderId orderId) {
deleteByOrderId(orderId);
}
void deleteByOrderId(OrderId orderId);
@Override
default boolean existsById(OrderId orderId) {
return existsByOrderId(orderId);
}
boolean existsByOrderId(OrderId orderId);
}

5. Custom Implementation with Complex Logic

package com.example.ecommerce.order.repository.impl;
import com.example.ecommerce.order.model.*;
import com.example.ecommerce.order.repository.OrderRepository;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.List;
import java.util.Optional;
@Component
public class OrderRepositoryImpl implements OrderRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
@Cacheable(value = "orders", key = "#orderId.value")
public Optional<Order> findById(OrderId orderId) {
return Optional.ofNullable(entityManager.find(Order.class, orderId.value()));
}
@Override
public List<Order> findAll() {
return entityManager.createQuery("SELECT o FROM Order o", Order.class)
.getResultList();
}
@Override
public Order save(Order order) {
if (order.getOrderId() == null) {
order.setOrderId(OrderId.generate());
entityManager.persist(order);
return order;
} else {
return entityManager.merge(order);
}
}
@Override
public void delete(Order order) {
entityManager.remove(order);
}
@Override
public void deleteById(OrderId orderId) {
entityManager.createQuery("DELETE FROM Order o WHERE o.orderId = :orderId")
.setParameter("orderId", orderId)
.executeUpdate();
}
@Override
public boolean existsById(OrderId orderId) {
Long count = entityManager.createQuery(
"SELECT COUNT(o) FROM Order o WHERE o.orderId = :orderId", Long.class)
.setParameter("orderId", orderId)
.getSingleResult();
return count > 0;
}
@Override
public long count() {
return entityManager.createQuery("SELECT COUNT(o) FROM Order o", Long.class)
.getSingleResult();
}
@Override
public List<Order> findByCustomerId(CustomerId customerId) {
return entityManager.createQuery(
"SELECT o FROM Order o WHERE o.customerId = :customerId", Order.class)
.setParameter("customerId", customerId)
.getResultList();
}
@Override
public boolean customerHasActiveOrders(CustomerId customerId) {
List<OrderStatus> activeStatuses = List.of(OrderStatus.DRAFT, OrderStatus.SUBMITTED, OrderStatus.PAID);
Long count = entityManager.createQuery(
"SELECT COUNT(o) FROM Order o WHERE o.customerId = :customerId AND o.status IN :statuses", Long.class)
.setParameter("customerId", customerId)
.setParameter("statuses", activeStatuses)
.getSingleResult();
return count > 0;
}
}

6. Specification Pattern for Complex Queries

package com.example.ddd.repository;
import java.util.List;
/**
* Specification pattern for building complex queries
*/
public interface Specification<T> {
boolean isSatisfiedBy(T entity);
// Could include toPredicate() for JPA Criteria API
}
// Usage example
public class OrderSpecifications {
public static Specification<Order> fromCustomer(CustomerId customerId) {
return order -> order.getCustomerId().equals(customerId);
}
public static Specification<Order> withStatus(OrderStatus status) {
return order -> order.getStatus() == status;
}
public static Specification<Order> withTotalGreaterThan(Money amount) {
return order -> order.getTotalAmount().compareTo(amount) > 0;
}
}
// Enhanced repository with specifications
public interface SpecificationRepository<T, ID> extends Repository<T, ID> {
List<T> findAll(Specification<T> spec);
long count(Specification<T> spec);
boolean exists(Specification<T> spec);
}

7. Service Layer Usage

package com.example.ecommerce.order.service;
import com.example.ecommerce.order.model.*;
import com.example.ecommerce.order.repository.OrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public OrderId createOrder(CustomerId customerId) {
// Check business rules
if (orderRepository.customerHasActiveOrders(customerId)) {
throw new IllegalStateException("Customer has active orders");
}
Order order = Order.createOrder(customerId);
return orderRepository.save(order).getOrderId();
}
public void addProductToOrder(OrderId orderId, ProductId productId, int quantity) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.addLineItem(productId, quantity);
orderRepository.save(order); // Persist the entire aggregate
}
public void submitOrder(OrderId orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.submit();
orderRepository.save(order);
// Domain event could be published here
}
@Transactional(readOnly = true)
public List<Order> getCustomerOrders(CustomerId customerId) {
return orderRepository.findByCustomerId(customerId);
}
@Transactional(readOnly = true)
public List<OrderSummary> getCustomerOrderSummaries(CustomerId customerId) {
return orderRepository.findOrderSummariesByCustomer(customerId);
}
}

8. Testing the Repository

package com.example.ecommerce.order.repository;
import com.example.ecommerce.order.model.*;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@ActiveProfiles("test")
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Test
void shouldSaveAndRetrieveOrder() {
// Given
CustomerId customerId = new CustomerId("cust-123");
Order order = Order.createOrder(customerId);
// When
Order saved = orderRepository.save(order);
Optional<Order> found = orderRepository.findById(saved.getOrderId());
// Then
assertTrue(found.isPresent());
assertEquals(customerId, found.get().getCustomerId());
assertEquals(OrderStatus.DRAFT, found.get().getStatus());
}
@Test
void shouldFindOrdersByCustomer() {
// Given
CustomerId customerId = new CustomerId("cust-456");
Order order1 = orderRepository.save(Order.createOrder(customerId));
Order order2 = orderRepository.save(Order.createOrder(customerId));
// When
List<Order> customerOrders = orderRepository.findByCustomerId(customerId);
// Then
assertEquals(2, customerOrders.size());
assertTrue(customerOrders.stream()
.allMatch(order -> order.getCustomerId().equals(customerId)));
}
}

Best Practices

1. Repository Design Principles

  • One repository per aggregate root - not per entity
  • Return aggregates, not individual entities - maintain consistency boundaries
  • Use domain language in method names and queries
  • Hide persistence details from domain services

2. Query Optimization

public interface OrderRepository {
// Use projections for read-heavy operations
List<OrderSummary> findOrderSummariesByCustomer(CustomerId customerId);
List<OrderId> findOrderIdsByStatus(OrderStatus status);
// Use pagination for large datasets
Page<Order> findByCustomerId(CustomerId customerId, Pageable pageable);
}

3. Caching Strategy

@Repository
public interface OrderRepository {
@Cacheable("orders")
Optional<Order> findById(OrderId orderId);
@CacheEvict(value = "orders", key = "#order.orderId.value")
Order save(Order order);
}

Common Pitfalls

Creating repositories for non-aggregate entities
Exposing database concerns in repository interface
Returning JPA entities directly to presentation layer
Mixing transaction management in repository layer
Creating overly generic repositories with complex inheritance

Conclusion

The Repository Pattern is fundamental in DDD for maintaining a clean separation between domain logic and persistence concerns. By providing a collection-like abstraction, it allows the domain model to remain focused on business rules while efficiently handling data access.

Key takeaways:

  • Repositories work with aggregate roots, not individual entities
  • They provide a domain-centric API using domain language
  • Implementation details are hidden behind the interface
  • They support complex queries while maintaining abstraction
  • Proper implementation enables testability and maintainability

When implemented correctly, the Repository Pattern creates a robust foundation for building scalable, maintainable domain-driven applications in Java.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper