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.