mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 14:09:34 +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,772 @@
|
|||
# Use Case Template (Java)
|
||||
|
||||
Template for creating application layer use cases in Java 21+.
|
||||
|
||||
## Pattern
|
||||
|
||||
A use case:
|
||||
- Is one file, one use case (not a service with multiple methods)
|
||||
- Has a single `execute()` method
|
||||
- Uses constructor injection for dependencies
|
||||
- Returns `Result<Error, Response>` for safe error handling
|
||||
- Orchestrates domain objects and repositories
|
||||
- Maps domain errors to application errors at boundaries
|
||||
- Can raise domain events for eventually consistent communication
|
||||
|
||||
## Example 1: WithdrawMoneyUseCase
|
||||
|
||||
```java
|
||||
package com.example.application.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;
|
||||
|
||||
/**
|
||||
* WithdrawMoneyUseCase - withdraw money from an account.
|
||||
*
|
||||
* One use case per file - clear responsibility.
|
||||
* Coordinates domain and infrastructure:
|
||||
* 1. Load account from repository
|
||||
* 2. Execute withdrawal on aggregate
|
||||
* 3. Persist updated aggregate
|
||||
* 4. Publish domain events for subscribers
|
||||
* 5. Return result with response DTO
|
||||
*
|
||||
* Errors:
|
||||
* - Account not found → repository error
|
||||
* - Insufficient funds → domain error
|
||||
* - Database failure → infrastructure error
|
||||
*/
|
||||
public class WithdrawMoneyUseCase {
|
||||
private static final Logger logger = LoggerFactory.getLogger(WithdrawMoneyUseCase.class);
|
||||
|
||||
private final AccountRepository accountRepository;
|
||||
private final DomainEventPublisher eventPublisher;
|
||||
private final UnitOfWork unitOfWork;
|
||||
|
||||
/**
|
||||
* Constructor injection - dependencies passed, not created.
|
||||
* Easier to test and swap implementations.
|
||||
*/
|
||||
public WithdrawMoneyUseCase(
|
||||
AccountRepository accountRepository,
|
||||
DomainEventPublisher eventPublisher,
|
||||
UnitOfWork unitOfWork
|
||||
) {
|
||||
this.accountRepository = accountRepository;
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute withdrawal use case.
|
||||
*
|
||||
* @param request containing account ID and amount
|
||||
* @return success with response (new balance), or failure with reason
|
||||
*/
|
||||
public Result<WithdrawError, WithdrawResponse> execute(
|
||||
WithdrawRequest request
|
||||
) {
|
||||
// Phase 1: Validate request
|
||||
try {
|
||||
request.validate();
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.warn("Invalid withdrawal request: " + e.getMessage());
|
||||
return failure(new InvalidRequestError(e.getMessage()));
|
||||
}
|
||||
|
||||
// Phase 2: Load aggregate
|
||||
Result<RepositoryError, Account> accountResult = accountRepository.findById(
|
||||
request.accountId()
|
||||
);
|
||||
|
||||
if (accountResult instanceof Failure f) {
|
||||
logger.warn("Account not found: " + request.accountId().value());
|
||||
return failure(mapRepositoryError(f.error()));
|
||||
}
|
||||
|
||||
Account account = ((Success<RepositoryError, Account>) (Object) accountResult).value();
|
||||
|
||||
// Phase 3: Execute domain logic
|
||||
Result<AccountError, Void> withdrawResult = account.withdraw(
|
||||
request.amount()
|
||||
);
|
||||
|
||||
if (withdrawResult instanceof Failure f) {
|
||||
// Map domain error to application error
|
||||
AccountError domainError = f.error();
|
||||
WithdrawError appError = mapDomainError(domainError);
|
||||
|
||||
if (domainError instanceof InsufficientFundsError) {
|
||||
logger.warn("Withdrawal rejected: " + appError.message());
|
||||
} else {
|
||||
logger.warn("Withdrawal failed: " + appError.message());
|
||||
}
|
||||
|
||||
return failure(appError);
|
||||
}
|
||||
|
||||
// Phase 4: Persist updated aggregate
|
||||
Result<RepositoryError, Void> saveResult = unitOfWork.withTransaction(() ->
|
||||
accountRepository.save(account)
|
||||
);
|
||||
|
||||
if (saveResult instanceof Failure f) {
|
||||
logger.error("Failed to save account after withdrawal: " + f.error().message());
|
||||
return failure(mapRepositoryError(f.error()));
|
||||
}
|
||||
|
||||
// Phase 5: Publish domain events
|
||||
account.events().forEach(event -> {
|
||||
logger.info("Publishing event: " + event.getClass().getSimpleName());
|
||||
eventPublisher.publish(event);
|
||||
});
|
||||
|
||||
// Phase 6: Return response
|
||||
return success(new WithdrawResponse(
|
||||
account.id().value(),
|
||||
account.balance().formatted(),
|
||||
account.status().toString()
|
||||
));
|
||||
}
|
||||
|
||||
// ================== Error Mapping ==================
|
||||
|
||||
/**
|
||||
* Map domain errors to application-level errors.
|
||||
* Each use case can map differently depending on context.
|
||||
*/
|
||||
private WithdrawError mapDomainError(AccountError error) {
|
||||
return switch (error) {
|
||||
case InsufficientFundsError e ->
|
||||
new InsufficientFundsError(
|
||||
e.required().formatted(),
|
||||
e.available().formatted()
|
||||
);
|
||||
case AccountClosedError e ->
|
||||
new AccountClosedError(e.id().value());
|
||||
case AccountFrozenError e ->
|
||||
new AccountFrozenError(e.id().value());
|
||||
case InvalidAmountError e ->
|
||||
new InvalidAmountError(e.amount().formatted());
|
||||
case InvalidOperationError e ->
|
||||
new OperationFailedError(e.message());
|
||||
default ->
|
||||
new OperationFailedError("Unexpected error: " + error.message());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map repository/infrastructure errors to application errors.
|
||||
* Hides infrastructure details from API layer.
|
||||
*/
|
||||
private WithdrawError mapRepositoryError(RepositoryError error) {
|
||||
return switch (error) {
|
||||
case NotFoundError e ->
|
||||
new AccountNotFoundError(e.entityId());
|
||||
case ConnectionError e -> {
|
||||
logger.error("Database connection error: " + e.message());
|
||||
yield new OperationFailedError("Database unavailable");
|
||||
}
|
||||
case SerializationError e -> {
|
||||
logger.error("Serialization error: " + e.message());
|
||||
yield new OperationFailedError("System error");
|
||||
}
|
||||
default -> {
|
||||
logger.error("Unexpected repository error: " + error.message());
|
||||
yield new OperationFailedError("System error");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Input/Output DTOs
|
||||
|
||||
```java
|
||||
package com.example.application.account;
|
||||
|
||||
import com.example.domain.account.AccountId;
|
||||
import com.example.domain.account.Money;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* WithdrawRequest - input DTO.
|
||||
*
|
||||
* Validation in constructor (compact if record, or method if class).
|
||||
* Separates API layer concerns from domain.
|
||||
*/
|
||||
public record WithdrawRequest(
|
||||
AccountId accountId,
|
||||
Money amount
|
||||
) {
|
||||
public WithdrawRequest {
|
||||
if (accountId == null) {
|
||||
throw new IllegalArgumentException("Account ID required");
|
||||
}
|
||||
if (amount == null) {
|
||||
throw new IllegalArgumentException("Amount required");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit validation for complex rules.
|
||||
*/
|
||||
public void validate() {
|
||||
if (amount.isNegativeOrZero()) {
|
||||
throw new IllegalArgumentException("Amount must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WithdrawResponse - output DTO.
|
||||
*
|
||||
* Contains information for API response.
|
||||
* Never contains internal IDs or implementation details.
|
||||
*/
|
||||
public record WithdrawResponse(
|
||||
String accountId,
|
||||
String newBalance,
|
||||
String accountStatus
|
||||
) {}
|
||||
```
|
||||
|
||||
## Use Case Errors
|
||||
|
||||
```java
|
||||
package com.example.application.account;
|
||||
|
||||
/**
|
||||
* WithdrawError - application-layer error for withdrawal.
|
||||
*
|
||||
* Sealed interface restricts implementations.
|
||||
* More specific than domain errors, less specific than domain.
|
||||
*/
|
||||
public sealed interface WithdrawError permits
|
||||
InsufficientFundsError,
|
||||
AccountClosedError,
|
||||
AccountFrozenError,
|
||||
InvalidAmountError,
|
||||
AccountNotFoundError,
|
||||
InvalidRequestError,
|
||||
OperationFailedError {
|
||||
|
||||
String message();
|
||||
}
|
||||
|
||||
public record InsufficientFundsError(
|
||||
String required,
|
||||
String available
|
||||
) implements WithdrawError {
|
||||
@Override
|
||||
public String message() {
|
||||
return String.format(
|
||||
"Insufficient funds: need %s, have %s",
|
||||
required, available
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public record AccountClosedError(String accountId) implements WithdrawError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Account is closed: " + accountId;
|
||||
}
|
||||
}
|
||||
|
||||
public record AccountFrozenError(String accountId) implements WithdrawError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Account is frozen: " + accountId;
|
||||
}
|
||||
}
|
||||
|
||||
public record InvalidAmountError(String amount) implements WithdrawError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Invalid amount: " + amount;
|
||||
}
|
||||
}
|
||||
|
||||
public record AccountNotFoundError(String accountId) implements WithdrawError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Account not found: " + accountId;
|
||||
}
|
||||
}
|
||||
|
||||
public record InvalidRequestError(String reason) implements WithdrawError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Invalid request: " + reason;
|
||||
}
|
||||
}
|
||||
|
||||
public record OperationFailedError(String reason) implements WithdrawError {
|
||||
@Override
|
||||
public String message() {
|
||||
return "Operation failed: " + reason;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example 2: TransferMoneyUseCase
|
||||
|
||||
```java
|
||||
package com.example.application.transfer;
|
||||
|
||||
import com.example.domain.account.*;
|
||||
import com.example.domain.transfer.*;
|
||||
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;
|
||||
|
||||
/**
|
||||
* TransferMoneyUseCase - transfer money between accounts.
|
||||
*
|
||||
* More complex use case coordinating multiple aggregates.
|
||||
* Handles transaction boundaries and eventual consistency.
|
||||
*
|
||||
* Two ways to handle:
|
||||
* 1. Distributed transaction (2-phase commit) - complex
|
||||
* 2. Saga pattern - better for async, but more code
|
||||
* 3. Event sourcing - captures all state changes
|
||||
*
|
||||
* This example uses approach 1 (simplified).
|
||||
*/
|
||||
public class TransferMoneyUseCase {
|
||||
private static final Logger logger = LoggerFactory.getLogger(TransferMoneyUseCase.class);
|
||||
|
||||
private final AccountRepository accountRepository;
|
||||
private final TransferRepository transferRepository;
|
||||
private final DomainEventPublisher eventPublisher;
|
||||
private final UnitOfWork unitOfWork;
|
||||
private final TransferIdGenerator transferIdGenerator;
|
||||
|
||||
public TransferMoneyUseCase(
|
||||
AccountRepository accountRepository,
|
||||
TransferRepository transferRepository,
|
||||
DomainEventPublisher eventPublisher,
|
||||
UnitOfWork unitOfWork,
|
||||
TransferIdGenerator transferIdGenerator
|
||||
) {
|
||||
this.accountRepository = accountRepository;
|
||||
this.transferRepository = transferRepository;
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.unitOfWork = unitOfWork;
|
||||
this.transferIdGenerator = transferIdGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute money transfer between accounts.
|
||||
*/
|
||||
public Result<TransferError, TransferResponse> execute(
|
||||
TransferRequest request
|
||||
) {
|
||||
// Validate request
|
||||
try {
|
||||
request.validate();
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.warn("Invalid transfer request: " + e.getMessage());
|
||||
return failure(new InvalidTransferRequestError(e.getMessage()));
|
||||
}
|
||||
|
||||
// Load source account
|
||||
Result<RepositoryError, Account> sourceResult = accountRepository.findById(
|
||||
request.sourceAccountId()
|
||||
);
|
||||
if (sourceResult instanceof Failure f) {
|
||||
return failure(new SourceAccountNotFoundError(request.sourceAccountId().value()));
|
||||
}
|
||||
Account source = ((Success) (Object) sourceResult).value();
|
||||
|
||||
// Load destination account
|
||||
Result<RepositoryError, Account> destResult = accountRepository.findById(
|
||||
request.destinationAccountId()
|
||||
);
|
||||
if (destResult instanceof Failure f) {
|
||||
return failure(new DestinationAccountNotFoundError(request.destinationAccountId().value()));
|
||||
}
|
||||
Account destination = ((Success) (Object) destResult).value();
|
||||
|
||||
// Check accounts are different
|
||||
if (source.id().equals(destination.id())) {
|
||||
logger.warn("Transfer attempted to same account");
|
||||
return failure(new SameAccountError());
|
||||
}
|
||||
|
||||
// Execute transfer within transaction
|
||||
return unitOfWork.withTransaction(() -> {
|
||||
// Withdraw from source
|
||||
Result<AccountError, Void> withdrawResult = source.withdraw(request.amount());
|
||||
if (withdrawResult instanceof Failure f) {
|
||||
AccountError error = f.error();
|
||||
if (error instanceof InsufficientFundsError e) {
|
||||
logger.warn("Transfer rejected: insufficient funds");
|
||||
return failure(mapError(e));
|
||||
}
|
||||
logger.warn("Source withdrawal failed: " + error.message());
|
||||
return failure(new TransferFailedError(error.message()));
|
||||
}
|
||||
|
||||
// Deposit to destination
|
||||
Result<AccountError, Void> depositResult = destination.deposit(request.amount());
|
||||
if (depositResult instanceof Failure f) {
|
||||
logger.error("Destination deposit failed (source already withdrawn!)");
|
||||
// This is a problem - source is withdrawn but dest failed
|
||||
// In production, would need compensating transaction
|
||||
return failure(new TransferFailedError("Deposit failed after withdrawal"));
|
||||
}
|
||||
|
||||
// Create transfer aggregate to track it
|
||||
TransferId transferId = transferIdGenerator.generate();
|
||||
Result<TransferError, Transfer> createResult = Transfer.create(
|
||||
transferId,
|
||||
source.id(),
|
||||
destination.id(),
|
||||
request.amount()
|
||||
);
|
||||
|
||||
if (createResult instanceof Failure f) {
|
||||
logger.error("Transfer aggregate creation failed");
|
||||
return failure(f.error());
|
||||
}
|
||||
|
||||
Transfer transfer = ((Success) (Object) createResult).value();
|
||||
|
||||
// Mark transfer as completed
|
||||
transfer.complete();
|
||||
|
||||
// Persist both accounts and transfer
|
||||
accountRepository.save(source);
|
||||
accountRepository.save(destination);
|
||||
transferRepository.save(transfer);
|
||||
|
||||
// Publish all events
|
||||
source.events().forEach(eventPublisher::publish);
|
||||
destination.events().forEach(eventPublisher::publish);
|
||||
transfer.events().forEach(eventPublisher::publish);
|
||||
|
||||
logger.info("Transfer completed: " + transferId.value() +
|
||||
" from " + source.id().value() +
|
||||
" to " + destination.id().value() +
|
||||
" amount " + request.amount().formatted());
|
||||
|
||||
return success(new TransferResponse(
|
||||
transfer.id().value(),
|
||||
transfer.status().toString(),
|
||||
request.amount().formatted()
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// Error mapping helpers...
|
||||
}
|
||||
|
||||
public record TransferRequest(
|
||||
AccountId sourceAccountId,
|
||||
AccountId destinationAccountId,
|
||||
Money amount
|
||||
) {
|
||||
public TransferRequest {
|
||||
if (sourceAccountId == null || destinationAccountId == null || amount == null) {
|
||||
throw new IllegalArgumentException("All fields required");
|
||||
}
|
||||
}
|
||||
|
||||
public void validate() {
|
||||
if (amount.isNegativeOrZero()) {
|
||||
throw new IllegalArgumentException("Amount must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record TransferResponse(
|
||||
String transferId,
|
||||
String status,
|
||||
String amount
|
||||
) {}
|
||||
```
|
||||
|
||||
## Use Case with Query (Read Operation)
|
||||
|
||||
```java
|
||||
package com.example.application.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;
|
||||
|
||||
/**
|
||||
* GetAccountBalanceUseCase - retrieve account balance (query, no mutation).
|
||||
*
|
||||
* Read-only use case - doesn't modify domain state.
|
||||
* Still returns Result for consistency.
|
||||
*/
|
||||
public class GetAccountBalanceUseCase {
|
||||
private final AccountRepository accountRepository;
|
||||
|
||||
public GetAccountBalanceUseCase(AccountRepository accountRepository) {
|
||||
this.accountRepository = accountRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute query for account balance.
|
||||
*/
|
||||
public Result<GetBalanceError, GetBalanceResponse> execute(
|
||||
GetBalanceRequest request
|
||||
) {
|
||||
Result<RepositoryError, Account> result = accountRepository.findById(
|
||||
request.accountId()
|
||||
);
|
||||
|
||||
if (result instanceof Failure f) {
|
||||
return failure(new AccountNotFoundError(request.accountId().value()));
|
||||
}
|
||||
|
||||
Account account = ((Success<RepositoryError, Account>) (Object) result).value();
|
||||
|
||||
return success(new GetBalanceResponse(
|
||||
account.id().value(),
|
||||
account.balance().formatted(),
|
||||
account.status().toString()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
public record GetBalanceRequest(AccountId accountId) {
|
||||
public GetBalanceRequest {
|
||||
if (accountId == null) {
|
||||
throw new IllegalArgumentException("Account ID required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record GetBalanceResponse(
|
||||
String accountId,
|
||||
String balance,
|
||||
String status
|
||||
) {}
|
||||
|
||||
public sealed interface GetBalanceError permits
|
||||
AccountNotFoundError,
|
||||
OperationFailedError {}
|
||||
```
|
||||
|
||||
## Testing Use Cases
|
||||
|
||||
```java
|
||||
public class WithdrawMoneyUseCaseTest {
|
||||
private WithdrawMoneyUseCase useCase;
|
||||
private AccountRepository accountRepository;
|
||||
private DomainEventPublisher eventPublisher;
|
||||
private UnitOfWork unitOfWork;
|
||||
|
||||
@Before
|
||||
public void setup() {
|
||||
// Use mock implementations
|
||||
accountRepository = mock(AccountRepository.class);
|
||||
eventPublisher = mock(DomainEventPublisher.class);
|
||||
unitOfWork = mock(UnitOfWork.class);
|
||||
|
||||
useCase = new WithdrawMoneyUseCase(
|
||||
accountRepository,
|
||||
eventPublisher,
|
||||
unitOfWork
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldWithdrawSuccessfully() {
|
||||
// Arrange
|
||||
Account account = createTestAccount(Money.usd(100_00));
|
||||
when(accountRepository.findById(any()))
|
||||
.thenReturn(success(account));
|
||||
when(unitOfWork.withTransaction(any()))
|
||||
.thenAnswer(inv -> {
|
||||
// Execute the lambda
|
||||
var lambda = inv.getArgument(0);
|
||||
return ((java.util.function.Supplier) lambda).get();
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = useCase.execute(new WithdrawRequest(
|
||||
account.id(),
|
||||
Money.usd(50_00)
|
||||
));
|
||||
|
||||
// Assert
|
||||
assertThat(result).satisfies(r -> {
|
||||
assertThat(r).isInstanceOf(Success.class);
|
||||
if (r instanceof Success<WithdrawError, WithdrawResponse> s) {
|
||||
assertThat(s.value().newBalance())
|
||||
.contains("50.00"); // $100 - $50 = $50
|
||||
}
|
||||
});
|
||||
|
||||
// Verify events published
|
||||
verify(eventPublisher, times(1)).publish(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRejectInsufficientFunds() {
|
||||
// Arrange
|
||||
Account account = createTestAccount(Money.usd(30_00));
|
||||
when(accountRepository.findById(any()))
|
||||
.thenReturn(success(account));
|
||||
|
||||
// Act
|
||||
var result = useCase.execute(new WithdrawRequest(
|
||||
account.id(),
|
||||
Money.usd(50_00) // More than balance
|
||||
));
|
||||
|
||||
// Assert
|
||||
assertThat(result).satisfies(r -> {
|
||||
assertThat(r).isInstanceOf(Failure.class);
|
||||
if (r instanceof Failure<WithdrawError, WithdrawResponse> f) {
|
||||
assertThat(f.error())
|
||||
.isInstanceOf(InsufficientFundsError.class);
|
||||
}
|
||||
});
|
||||
|
||||
// Verify account not saved
|
||||
verify(accountRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturnAccountNotFoundError() {
|
||||
// Arrange
|
||||
when(accountRepository.findById(any()))
|
||||
.thenReturn(failure(
|
||||
new NotFoundError("Account", "nonexistent")
|
||||
));
|
||||
|
||||
// Act
|
||||
var result = useCase.execute(new WithdrawRequest(
|
||||
new AccountId("nonexistent"),
|
||||
Money.usd(10_00)
|
||||
));
|
||||
|
||||
// Assert
|
||||
assertThat(result).satisfies(r -> {
|
||||
assertThat(r).isInstanceOf(Failure.class);
|
||||
if (r instanceof Failure<WithdrawError, WithdrawResponse> f) {
|
||||
assertThat(f.error())
|
||||
.isInstanceOf(AccountNotFoundError.class);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UnitOfWork Pattern for Transactions
|
||||
|
||||
```java
|
||||
package com.example.application.shared;
|
||||
|
||||
import com.example.shared.result.Result;
|
||||
|
||||
/**
|
||||
* UnitOfWork - manages transaction boundaries.
|
||||
*
|
||||
* Ensures all changes in a use case are committed atomically.
|
||||
* Implementation handles JDBC transactions, Spring, Hibernate, etc.
|
||||
*/
|
||||
public interface UnitOfWork {
|
||||
/**
|
||||
* Execute work within a transaction.
|
||||
* Commits if no exception, rolls back on exception or Result.failure.
|
||||
*/
|
||||
<E, T> Result<E, T> withTransaction(
|
||||
java.util.function.Supplier<Result<E, T>> work
|
||||
);
|
||||
|
||||
/**
|
||||
* Synchronous version - work runs in transaction.
|
||||
*/
|
||||
void withTransactionVoid(Runnable work) throws Exception;
|
||||
}
|
||||
|
||||
/**
|
||||
* JDBC implementation of UnitOfWork.
|
||||
*/
|
||||
public class JdbcUnitOfWork implements UnitOfWork {
|
||||
private final DataSource dataSource;
|
||||
|
||||
public JdbcUnitOfWork(DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <E, T> Result<E, T> withTransaction(
|
||||
java.util.function.Supplier<Result<E, T>> work
|
||||
) {
|
||||
Connection conn = null;
|
||||
try {
|
||||
conn = dataSource.getConnection();
|
||||
conn.setAutoCommit(false);
|
||||
|
||||
// Execute work
|
||||
Result<E, T> result = work.get();
|
||||
|
||||
if (result instanceof Failure) {
|
||||
conn.rollback();
|
||||
} else {
|
||||
conn.commit();
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
if (conn != null) {
|
||||
try { conn.rollback(); } catch (SQLException ex) {
|
||||
// Log rollback error
|
||||
}
|
||||
}
|
||||
throw new RuntimeException("Transaction failed", e);
|
||||
} finally {
|
||||
if (conn != null) {
|
||||
try { conn.close(); } catch (SQLException e) {
|
||||
// Log close error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
1. **One Use Case Per File**: Single responsibility, easy to find and test
|
||||
2. **Constructor Injection**: Dependencies passed in, not created
|
||||
3. **Result Returns**: All methods return `Result<Error, Response>`
|
||||
4. **Error Mapping**: Domain errors → Application errors at boundary
|
||||
5. **Orchestration**: Coordinates domain objects and repositories
|
||||
6. **Transaction Boundaries**: Uses UnitOfWork for atomicity
|
||||
7. **Event Publishing**: Raises domain events for subscribers
|
||||
8. **Logging Strategy**: WARN for business errors, ERROR for technical
|
||||
9. **Input Validation**: Validate DTOs before using
|
||||
10. **No Business Logic in DTOs**: DTOs are data, logic in aggregates
|
||||
|
||||
## Best Practices
|
||||
|
||||
- One execute() method per use case class
|
||||
- Use constructor injection for testability
|
||||
- Catch all exceptions at infrastructure boundary
|
||||
- Log domain errors at WARN level (expected failures)
|
||||
- Log infrastructure errors at ERROR level (unexpected)
|
||||
- Return Result types for all operations
|
||||
- Map errors at layer boundaries
|
||||
- Use UnitOfWork for transaction coordination
|
||||
- Publish domain events after persistence succeeds
|
||||
- Never expose domain objects in responses (use DTOs)
|
||||
- Keep use cases focused on orchestration, not logic
|
||||
Loading…
Add table
Add a link
Reference in a new issue