1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 10:39:35 +01:00
effigenix/bin/.claude/skills/ddd-model/languages/java/templates/Repository.java.md
2026-02-18 23:25:12 +01:00

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 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

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

  1. Interface in Domain: Repository interface lives in domain layer only
  2. Result Returns: All methods return Result<Error, T> 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