Repository Pattern Implementation
Overview
The Repository Pattern abstracts data access logic from business logic, providing a clean interface for CRUD operations and queries on aggregate roots.
Implementation in StockEase
Pattern Structure
graph TD
A[Business Logic Service] -->|uses| B[Repository Interface
Spring Data JPA] B -->|extends| C[JpaRepository T, ID] C -->|Spring generates| D[Repository Implementation
Proxy] D -->|uses| E[EntityManager
Hibernate] E -->|executes| F[SQL Queries] F -->|accesses| G[PostgreSQL Database] style A fill:#e3f2fd style B fill:#fff3e0 style C fill:#e8f5e9 style D fill:#f3e5f5 style E fill:#fce4ec style F fill:#e1f5fe style G fill:#f3e5f5
Spring Data JPA] B -->|extends| C[JpaRepository T, ID] C -->|Spring generates| D[Repository Implementation
Proxy] D -->|uses| E[EntityManager
Hibernate] E -->|executes| F[SQL Queries] F -->|accesses| G[PostgreSQL Database] style A fill:#e3f2fd style B fill:#fff3e0 style C fill:#e8f5e9 style D fill:#f3e5f5 style E fill:#fce4ec style F fill:#e1f5fe style G fill:#f3e5f5
Repository Interfaces
UserRepository
public interface UserRepository extends JpaRepository<User, UUID> {
// Spring Data JPA auto-generates implementation
// Spring derives queries from method names
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
boolean existsByUsername(String username);
// Custom queries using @Query
@Query("""
SELECT u FROM User u
WHERE u.role = :role
ORDER BY u.createdAt DESC
""")
List<User> findByRoleOrderByDate(@Param("role") Role role);
}ProductRepository
public interface ProductRepository extends JpaRepository<Product, UUID> {
// Query methods
Optional<Product> findBySku(String sku);
Page<Product> findByCategory(String category, Pageable pageable);
List<Product> findByCreatedBy(UUID userId);
// Exists check
boolean existsBySku(String sku);
// Custom query with parameters
@Query("""
SELECT p FROM Product p
WHERE p.price BETWEEN :minPrice AND :maxPrice
AND p.quantity > 0
ORDER BY p.price ASC
""")
List<Product> findAffordableInStock(
@Param("minPrice") BigDecimal minPrice,
@Param("maxPrice") BigDecimal maxPrice);
// Using @Query with LIKE
@Query("""
SELECT p FROM Product p
WHERE LOWER(p.name) LIKE LOWER(CONCAT('%', :search, '%'))
""")
Page<Product> searchByName(
@Param("search") String search,
Pageable pageable);
}Query Method Naming Convention
Spring Data JPA generates queries from method names:
| Method Name | Generated Query |
|---|---|
findBySku(String sku) |
SELECT * FROM products WHERE sku = ? |
findByCategory(String cat) |
SELECT * FROM products WHERE category = ? |
findByCategoryAndPrice(String cat, BigDecimal price) |
SELECT * FROM products WHERE category = ? AND price = ? |
findByCategoryOrPrice(...) |
SELECT * FROM products WHERE category = ? OR price = ? |
findByPriceGreaterThan(BigDecimal price) |
SELECT * FROM products WHERE price > ? |
findByPriceLessThanEqual(BigDecimal price) |
SELECT * FROM products WHERE price <= ? |
findBySkuContaining(String sku) |
SELECT * FROM products WHERE sku LIKE ? |
findBySkuIn(List<String> skus) |
SELECT * FROM products WHERE sku IN (?, ...) |
existsBySku(String sku) |
SELECT COUNT(*) > 0 FROM products WHERE sku = ? |
Usage Examples
In Service Layer
@Service
public class ProductService {
private final ProductRepository productRepository;
private final UserRepository userRepository;
@Transactional(readOnly = true)
public Page<ProductDTO> getProducts(
int page, int size, String category) {
Pageable pageable = PageRequest.of(page, size);
Page<Product> products = productRepository
.findByCategory(category, pageable);
return products.map(ProductDTO::fromEntity);
}
@Transactional
public ProductDTO createProduct(
CreateProductRequest request, UUID userId) {
// Check authorization
User user = userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
if (!user.getRole().equals(Role.ADMIN)) {
throw new AuthorizationException("Unauthorized");
}
// Check uniqueness
if (productRepository.existsBySku(request.getSku())) {
throw new ValidationException("SKU already exists");
}
// Create and save
Product product = new Product(request);
product.setCreatedBy(userId);
Product saved = productRepository.save(product);
return ProductDTO.fromEntity(saved);
}
@Transactional
public void deleteProduct(UUID id, UUID userId) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Product not found"));
productRepository.delete(product);
}
}Pagination
Using PageRequest
// Get page 0 (first page) with 20 items per page
Pageable pageable = PageRequest.of(0, 20);
Page<Product> page = productRepository.findAll(pageable);
// With sorting
Pageable pageable = PageRequest.of(
0, 20,
Sort.by(Sort.Order.asc("name")));
Page<Product> page = productRepository.findAll(pageable);
// Response to client
{
"content": [...],
"pageable": {
"pageNumber": 0,
"pageSize": 20,
"sort": [{"property": "name", "direction": "ASC"}]
},
"totalElements": 150,
"totalPages": 8,
"last": false,
"size": 20,
"number": 0,
"sort": [...]
}Custom Queries with @Query
JPQL (Java Persistence Query Language)
// JPQL uses entity names, not table names
@Query("SELECT p FROM Product p WHERE p.price > :minPrice")
List<Product> findExpensiveProducts(@Param("minPrice") BigDecimal minPrice);
// With joins
@Query("""
SELECT p FROM Product p
JOIN p.createdBy user
WHERE user.username = :username
""")
List<Product> findProductsByCreator(@Param("username") String username);
// With GROUP BY
@Query("""
SELECT p.category, COUNT(p)
FROM Product p
GROUP BY p.category
ORDER BY COUNT(p) DESC
""")
List<Object[]> getTopCategories();Native SQL
@Query(
value = """
SELECT p.* FROM products p
WHERE p.created_at > NOW() - INTERVAL '7 days'
ORDER BY p.price DESC
""",
nativeQuery = true
)
List<Product> findRecentProducts();Transaction Management
Read-Only Transactions
@Transactional(readOnly = true)
public Page<ProductDTO> getProducts(Pageable pageable) {
// Database optimizations for read-only
// No locks held after query
// Can rollback if needed
return productRepository.findAll(pageable);
}Write Transactions
@Transactional
public void createAndAudit(CreateProductRequest request) {
// Both operations succeed or both rollback
Product product = productRepository.save(new Product(request));
auditRepository.save(new AuditEvent(product.getId(), "CREATE"));
}
// If auditRepository.save throws exception:
// Product save is rolled back automaticallyAdvanced Patterns
Specifications (Dynamic Queries)
public interface ProductRepository
extends JpaRepository<Product, UUID>,
JpaSpecificationExecutor<Product> {
}
// Usage in service
public Page<ProductDTO> searchProducts(
String name, String category, BigDecimal maxPrice, Pageable pageable) {
Specification<Product> spec = Specification
.where(nameContains(name))
.and(categoryEquals(category))
.and(priceLessThan(maxPrice));
return productRepository.findAll(spec, pageable);
}
private static Specification<Product> nameContains(String name) {
return (root, query, cb) ->
name == null ? null :
cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
}Projections (Partial Data)
public interface ProductSummary {
UUID getId();
String getName();
BigDecimal getPrice();
}
public interface ProductRepository extends JpaRepository<Product, UUID> {
// Returns only specific fields
List<ProductSummary> findByCategory(String category);
}
// Usage
List<ProductSummary> summaries = productRepository.findByCategory("electronics");Performance Considerations
N+1 Query Problem
// ❌ PROBLEM: N+1 queries
List<Product> products = productRepository.findAll();
for (Product p : products) {
User creator = p.getCreatedBy(); // Additional query per product!
}
// âś… SOLUTION: Eager loading
@Query("""
SELECT DISTINCT p FROM Product p
LEFT JOIN FETCH p.createdBy
""")
List<Product> findAllWithCreator();Lazy vs Eager Loading
@Entity
public class Product {
@ManyToOne(fetch = FetchType.LAZY)
private User createdBy; // Only loaded when accessed
@OneToMany(fetch = FetchType.EAGER)
private Set<Review> reviews; // Always loaded
}Query Caching
@Cacheable("products")
public ProductDTO getProductById(UUID id) {
return productRepository.findById(id)
.map(ProductDTO::fromEntity)
.orElse(null);
}Testing Repository Methods
@DataJpaTest
public class ProductRepositoryTest {
@Autowired
private ProductRepository repository;
@Test
public void testFindBySku() {
Product product = new Product("Widget", "WIDGET-001");
repository.save(product);
Optional<Product> found = repository.findBySku("WIDGET-001");
assertTrue(found.isPresent());
assertEquals("Widget", found.get().getName());
}
}Best Practices
- Repository per Aggregate
- One repository per entity
- Don’t cross repository boundaries in queries
- Keep Queries Simple
- Use method naming for simple queries
- Use @Query for complex logic
- Avoid N+1 problems
- Transaction Boundaries
- Mark read operations as @Transactional(readOnly = true)
- Use @Transactional for write operations
- Error Handling
- Throw domain exceptions, not SQL exceptions
- Let service layer handle exceptions
- Testing
- Test repository methods independently
- Use @DataJpaTest for unit tests
- Use mock repositories in service tests
Document Version: 1.0
Last Updated: October 31, 2025
Status: Production Ready