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.