mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 21:39:57 +01:00
22 KiB
22 KiB
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
Resulttypes 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
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<RepositoryError, T> 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<RepositoryError, Void> save(Account account);
/**
* Retrieve account by ID.
*
* @param id the account identifier
* @return success with account if found, failure if not found
*/
Result<RepositoryError, Account> 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<RepositoryError, List<Account>> findByHolderUserId(UserId userId);
/**
* Check if account exists.
*
* @param id the account identifier
* @return success with boolean, failure if query fails
*/
Result<RepositoryError, Boolean> exists(AccountId id);
/**
* Delete account (soft or hard).
*
* @param id the account to delete
* @return success if deleted, failure otherwise
*/
Result<RepositoryError, Void> 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
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<RepositoryError, Void> 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<RepositoryError, Account> 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<AccountHolder> 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<RepositoryError, List<Account>> 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<Account> accounts = new ArrayList<>();
while (rs.next()) {
Account account = rowMapper.mapRow(rs);
// Load holders
List<AccountHolder> 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<RepositoryError, Boolean> 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<RepositoryError, Void> 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<AccountHolder> 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<AccountHolder> loadHolders(Connection conn, AccountId accountId)
throws SQLException {
String sql = "SELECT * FROM account_holders WHERE account_id = ? ORDER BY name";
List<AccountHolder> 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
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
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
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<RepositoryError, Account> 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<RepositoryError, Account> 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:
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
- Interface in Domain: Repository interface lives in domain layer only
- Result Returns: All methods return
Result<Error, T>for safety - No Exceptions in Domain: Infrastructure errors wrapped in Result
- Implementation Separate: Repository implementation in infrastructure layer
- SQL Isolation: SQL and row mapping in dedicated classes
- Transaction Boundaries: Handle savepoints and rollback in implementation
- Exception Logging: Log technical errors before transformation
- No Leaking Details: Domain layer never sees SQL or JDBC types
- Collection-Like API: Methods named after domain concepts, not CRUD
- 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