mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 13:59:36 +01:00
docs: and skills
This commit is contained in:
parent
e4f0665086
commit
ccd4ee534a
25 changed files with 10412 additions and 0 deletions
|
|
@ -0,0 +1,721 @@
|
|||
# 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<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
|
||||
|
||||
```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<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
|
||||
|
||||
```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<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:
|
||||
|
||||
```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<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
|
||||
Loading…
Add table
Add a link
Reference in a new issue