# Repository Template (Java) Template for creating repositories in Java 21+ following DDD and Clean Architecture. ## Pattern A repository: - Is an interface in the domain layer (no implementation details) - Provides methods to persist and retrieve aggregates - All methods return `Result` types for safety - Methods are named after domain concepts (not CRUD) - Implementation lives in infrastructure layer - Acts as a collection-like interface for aggregates ## Domain Layer: Interface ```java package com.example.domain.account; import com.example.shared.result.Result; import java.util.List; /** * AccountRepository defines persistence contract for Account aggregates. * * Lives in domain layer - no infrastructure dependencies. * Implementation in infrastructure layer. * * All methods return Result for safe error handling. * Domain layer never has hidden failures - all errors explicit in type. */ public interface AccountRepository { /** * Save an account (create or update). * * @param account the aggregate to persist * @return success if saved, failure with reason */ Result save(Account account); /** * Retrieve account by ID. * * @param id the account identifier * @return success with account if found, failure if not found */ Result findById(AccountId id); /** * Retrieve all accounts for a holder. * * @param userId the user ID to search for * @return success with list (possibly empty), failure if query fails */ Result> findByHolderUserId(UserId userId); /** * Check if account exists. * * @param id the account identifier * @return success with boolean, failure if query fails */ Result exists(AccountId id); /** * Delete account (soft or hard). * * @param id the account to delete * @return success if deleted, failure otherwise */ Result delete(AccountId id); } /** * Repository error type - infrastructure failures. * Sealed interface for type-safe error handling. */ public sealed interface RepositoryError permits NotFoundError, ConflictError, ConnectionError, SerializationError, UnexpectedError { String message(); } public record NotFoundError( String entityType, String entityId ) implements RepositoryError { @Override public String message() { return String.format("%s not found: %s", entityType, entityId); } } public record ConflictError( String reason, String entityId ) implements RepositoryError { @Override public String message() { return String.format("Conflict: %s (ID: %s)", reason, entityId); } } public record ConnectionError( String reason, Throwable cause ) implements RepositoryError { @Override public String message() { return "Database connection failed: " + reason; } } public record SerializationError( String reason, Throwable cause ) implements RepositoryError { @Override public String message() { return "Serialization error: " + reason; } } public record UnexpectedError( String reason, Throwable cause ) implements RepositoryError { @Override public String message() { return "Unexpected error: " + reason; } } ``` ## Infrastructure Layer: JDBC Implementation ```java package com.example.infrastructure.persistence.account; import com.example.domain.account.*; import com.example.shared.result.Result; import static com.example.shared.result.Result.success; import static com.example.shared.result.Result.failure; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.sql.*; import java.util.*; import javax.sql.DataSource; /** * JdbcAccountRepository - JDBC implementation of AccountRepository. * * Lives in infrastructure layer - handles all database details. * Translates between domain objects and relational schema. * * Exception handling: catches SQL exceptions, transforms to domain errors. * Logging: logs technical errors, not business rule violations. */ public class JdbcAccountRepository implements AccountRepository { private static final Logger logger = LoggerFactory.getLogger(JdbcAccountRepository.class); private final DataSource dataSource; private final AccountRowMapper rowMapper; public JdbcAccountRepository(DataSource dataSource, AccountRowMapper rowMapper) { this.dataSource = Objects.requireNonNull(dataSource); this.rowMapper = Objects.requireNonNull(rowMapper); } /** * Save account to database (insert or update). */ @Override public Result save(Account account) { String sql = "INSERT INTO accounts (id, balance, status, account_type, " + "credit_limit, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET " + "balance = EXCLUDED.balance, status = EXCLUDED.status, " + "updated_at = EXCLUDED.updated_at"; Connection conn = null; try { conn = dataSource.getConnection(); // Start transaction conn.setAutoCommit(false); try (var stmt = conn.prepareStatement(sql)) { stmt.setString(1, account.id().value()); stmt.setBigDecimal(2, account.balance().amount()); stmt.setString(3, account.status().toString()); stmt.setString(4, account.accountType().toString()); stmt.setBigDecimal(5, account.creditLimit().amount()); stmt.setTimestamp(6, Timestamp.from(account.createdAt())); stmt.setTimestamp(7, Timestamp.from(account.updatedAt())); stmt.executeUpdate(); } // Persist holders saveholders(conn, account.id(), account.holders()); // Commit transaction conn.commit(); return success(null); } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException rollbackEx) { logger.warn("Rollback failed", rollbackEx); } } logger.error("SQL error saving account " + account.id().value(), e); return failure( new ConnectionError("Failed to save account: " + e.getMessage(), e) ); } finally { if (conn != null) { try { conn.close(); } catch (SQLException e) { logger.warn("Error closing connection", e); } } } } /** * Find account by ID. */ @Override public Result findById(AccountId id) { String sql = "SELECT * FROM accounts WHERE id = ?"; Connection conn = null; try { conn = dataSource.getConnection(); try (var stmt = conn.prepareStatement(sql)) { stmt.setString(1, id.value()); var rs = stmt.executeQuery(); if (!rs.next()) { return failure(new NotFoundError("Account", id.value())); } Account account = rowMapper.mapRow(rs); // Load holders List holders = loadHolders(conn, id); account = Account.reconstruct( account.id(), account.balance(), account.accountType(), account.creditLimit(), account.status(), holders, account.createdAt(), account.updatedAt() ); return success(account); } } catch (SQLException e) { logger.error("SQL error finding account " + id.value(), e); return failure( new ConnectionError("Failed to find account: " + e.getMessage(), e) ); } finally { if (conn != null) { try { conn.close(); } catch (SQLException e) { logger.warn("Error closing connection", e); } } } } /** * Find all accounts for a user. */ @Override public Result> findByHolderUserId(UserId userId) { String sql = "SELECT DISTINCT a.* FROM accounts a " + "JOIN account_holders h ON a.id = h.account_id " + "WHERE h.user_id = ? " + "ORDER BY a.created_at DESC"; Connection conn = null; try { conn = dataSource.getConnection(); try (var stmt = conn.prepareStatement(sql)) { stmt.setString(1, userId.value()); var rs = stmt.executeQuery(); List accounts = new ArrayList<>(); while (rs.next()) { Account account = rowMapper.mapRow(rs); // Load holders List holders = loadHolders(conn, account.id()); account = Account.reconstruct( account.id(), account.balance(), account.accountType(), account.creditLimit(), account.status(), holders, account.createdAt(), account.updatedAt() ); accounts.add(account); } return success(accounts); } } catch (SQLException e) { logger.error("SQL error finding accounts for user " + userId.value(), e); return failure( new ConnectionError("Failed to find accounts: " + e.getMessage(), e) ); } finally { if (conn != null) { try { conn.close(); } catch (SQLException e) { logger.warn("Error closing connection", e); } } } } /** * Check if account exists. */ @Override public Result exists(AccountId id) { String sql = "SELECT 1 FROM accounts WHERE id = ?"; Connection conn = null; try { conn = dataSource.getConnection(); try (var stmt = conn.prepareStatement(sql)) { stmt.setString(1, id.value()); var rs = stmt.executeQuery(); return success(rs.next()); } } catch (SQLException e) { logger.error("SQL error checking account existence for " + id.value(), e); return failure( new ConnectionError("Failed to check account: " + e.getMessage(), e) ); } finally { if (conn != null) { try { conn.close(); } catch (SQLException e) { logger.warn("Error closing connection", e); } } } } /** * Delete account. */ @Override public Result delete(AccountId id) { String sql = "DELETE FROM accounts WHERE id = ?"; Connection conn = null; try { conn = dataSource.getConnection(); conn.setAutoCommit(false); try (var stmt = conn.prepareStatement(sql)) { stmt.setString(1, id.value()); int deleted = stmt.executeUpdate(); if (deleted == 0) { conn.rollback(); return failure(new NotFoundError("Account", id.value())); } // Delete holders too deleteHolders(conn, id); conn.commit(); return success(null); } catch (SQLException e) { conn.rollback(); throw e; } } catch (SQLException e) { logger.error("SQL error deleting account " + id.value(), e); return failure( new ConnectionError("Failed to delete account: " + e.getMessage(), e) ); } finally { if (conn != null) { try { conn.close(); } catch (SQLException e) { logger.warn("Error closing connection", e); } } } } // ================== Private Helpers ================== private void saveholders(Connection conn, AccountId accountId, List holders) throws SQLException { String deleteOldSql = "DELETE FROM account_holders WHERE account_id = ?"; try (var stmt = conn.prepareStatement(deleteOldSql)) { stmt.setString(1, accountId.value()); stmt.executeUpdate(); } String insertSql = "INSERT INTO account_holders (account_id, holder_id, name, role, email) " + "VALUES (?, ?, ?, ?, ?)"; try (var stmt = conn.prepareStatement(insertSql)) { for (AccountHolder holder : holders) { stmt.setString(1, accountId.value()); stmt.setString(2, holder.id().value()); stmt.setString(3, holder.name()); stmt.setString(4, holder.role().toString()); stmt.setString(5, holder.emailAddress()); stmt.executeUpdate(); } } } private List loadHolders(Connection conn, AccountId accountId) throws SQLException { String sql = "SELECT * FROM account_holders WHERE account_id = ? ORDER BY name"; List holders = new ArrayList<>(); try (var stmt = conn.prepareStatement(sql)) { stmt.setString(1, accountId.value()); var rs = stmt.executeQuery(); while (rs.next()) { AccountHolder holder = AccountHolder.create( new AccountHolderId(rs.getString("holder_id")), rs.getString("name"), AccountRole.valueOf(rs.getString("role")), rs.getString("email") ); holders.add(holder); } } return holders; } private void deleteHolders(Connection conn, AccountId accountId) throws SQLException { String sql = "DELETE FROM account_holders WHERE account_id = ?"; try (var stmt = conn.prepareStatement(sql)) { stmt.setString(1, accountId.value()); stmt.executeUpdate(); } } } ``` ## Row Mapper for Hydration ```java package com.example.infrastructure.persistence.account; import com.example.domain.account.*; import java.sql.ResultSet; import java.sql.SQLException; /** * AccountRowMapper - maps SQL result sets to Account aggregates. * * Part of infrastructure layer - handles database schema details. * Separated from repository for testability and single responsibility. */ public class AccountRowMapper { /** * Map a single database row to Account aggregate. * Note: Loads main account data but not child holders. */ public Account mapRow(ResultSet rs) throws SQLException { AccountId id = new AccountId(rs.getString("id")); Money balance = new Money( rs.getBigDecimal("balance"), "USD" // Assume currency column or hardcode ); AccountStatus status = AccountStatus.valueOf(rs.getString("status")); AccountType type = AccountType.valueOf(rs.getString("account_type")); Money creditLimit = new Money( rs.getBigDecimal("credit_limit"), "USD" ); java.time.Instant createdAt = rs.getTimestamp("created_at") .toInstant(); java.time.Instant updatedAt = rs.getTimestamp("updated_at") .toInstant(); // Return empty aggregate - holders loaded separately return Account.reconstruct( id, balance, type, creditLimit, status, List.of(), // Holders loaded separately createdAt, updatedAt ); } } ``` ## SQL Queries as Constants ```java package com.example.infrastructure.persistence.account; /** * AccountQueries - SQL constants for accounts. * * Centralizes SQL to make schema changes easier. * Easier to unit test and review SQL. */ public class AccountQueries { private AccountQueries() {} public static final String SAVE_ACCOUNT = "INSERT INTO accounts (id, balance, status, account_type, credit_limit, " + "created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET " + "balance = EXCLUDED.balance, status = EXCLUDED.status, " + "updated_at = EXCLUDED.updated_at"; public static final String FIND_BY_ID = "SELECT * FROM accounts WHERE id = ?"; public static final String FIND_BY_HOLDER_USER_ID = "SELECT DISTINCT a.* FROM accounts a " + "JOIN account_holders h ON a.id = h.account_id " + "WHERE h.user_id = ? " + "ORDER BY a.created_at DESC"; public static final String EXISTS = "SELECT 1 FROM accounts WHERE id = ?"; public static final String DELETE = "DELETE FROM accounts WHERE id = ?"; public static final String SAVE_HOLDERS = "INSERT INTO account_holders (account_id, holder_id, name, role, email) " + "VALUES (?, ?, ?, ?, ?)"; public static final String LOAD_HOLDERS = "SELECT * FROM account_holders WHERE account_id = ? ORDER BY name"; public static final String DELETE_HOLDERS = "DELETE FROM account_holders WHERE account_id = ?"; } ``` ## Testing the Repository ```java public class JdbcAccountRepositoryTest { private JdbcAccountRepository repository; private DataSource dataSource; @Before public void setup() { // Use test database dataSource = createTestDataSource(); repository = new JdbcAccountRepository(dataSource, new AccountRowMapper()); } @Test public void shouldSaveAndRetrieveAccount() { // Arrange AccountId id = AccountId.random(); Account account = Account.create( id, Money.usd(100_00), createOwner() ).orElseThrow(); // Act var saveResult = repository.save(account); var findResult = repository.findById(id); // Assert assertThat(saveResult).satisfies(r -> { assertThat(r).isInstanceOf(Success.class); }); assertThat(findResult).satisfies(r -> { assertThat(r).isInstanceOf(Success.class); if (r instanceof Success s) { assertThat(s.value().id()).isEqualTo(id); assertThat(s.value().balance()).isEqualTo(Money.usd(100_00)); } }); } @Test public void shouldReturnNotFoundForMissingAccount() { // Act var result = repository.findById(new AccountId("nonexistent")); // Assert assertThat(result).satisfies(r -> { assertThat(r).isInstanceOf(Failure.class); if (r instanceof Failure f) { assertThat(f.error()) .isInstanceOf(NotFoundError.class); } }); } @Test public void shouldDeleteAccount() { // Arrange Account account = Account.create(...).orElseThrow(); repository.save(account); // Act var deleteResult = repository.delete(account.id()); var findResult = repository.findById(account.id()); // Assert assertThat(deleteResult).satisfies(r -> { assertThat(r).isInstanceOf(Success.class); }); assertThat(findResult).satisfies(r -> { assertThat(r).isInstanceOf(Failure.class); }); } } ``` ## Contract Tests Use these to ensure all repository implementations follow the contract: ```java public abstract class AccountRepositoryContractTest { protected abstract AccountRepository createRepository(); private AccountRepository repository; @Before public void setup() { repository = createRepository(); } @Test public void shouldSaveNewAccount() { // Every repository implementation must support this Account account = createTestAccount(); var result = repository.save(account); assertThat(result).satisfies(r -> { assertThat(r).isInstanceOf(Success.class); }); } @Test public void shouldFindSavedAccount() { Account account = createTestAccount(); repository.save(account); var result = repository.findById(account.id()); assertThat(result).satisfies(r -> { assertThat(r).isInstanceOf(Success.class); }); } // Other contract tests... } // Implementation-specific test inherits contract public class JdbcAccountRepositoryContractTest extends AccountRepositoryContractTest { @Override protected AccountRepository createRepository() { return new JdbcAccountRepository(dataSource, new AccountRowMapper()); } } ``` ## Key Features 1. **Interface in Domain**: Repository interface lives in domain layer only 2. **Result Returns**: All methods return `Result` for safety 3. **No Exceptions in Domain**: Infrastructure errors wrapped in Result 4. **Implementation Separate**: Repository implementation in infrastructure layer 5. **SQL Isolation**: SQL and row mapping in dedicated classes 6. **Transaction Boundaries**: Handle savepoints and rollback in implementation 7. **Exception Logging**: Log technical errors before transformation 8. **No Leaking Details**: Domain layer never sees SQL or JDBC types 9. **Collection-Like API**: Methods named after domain concepts, not CRUD 10. **Contract Tests**: Use abstract test classes to verify all implementations ## Best Practices - Never expose repository implementation details to domain layer - Use Result types to maintain clean architecture boundary - Log infrastructure errors (ERROR level), business errors (WARN level) - Wrap external exceptions at repository boundary - Test repositories with contract tests - Use row mappers for schema-to-domain transformations - Keep queries in separate constant classes - Handle transactions explicitly - Use prepared statements to prevent SQL injection - Document which aggregate boundaries the repository crosses