mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:30:16 +01:00
685 lines
20 KiB
Markdown
685 lines
20 KiB
Markdown
# Project Structure for Java DDD
|
|
|
|
This guide covers organizing a Java project following Clean Architecture and Domain-Driven Design principles.
|
|
|
|
## Maven Structure
|
|
|
|
Standard Maven project organization:
|
|
|
|
```
|
|
project-root/
|
|
├── pom.xml
|
|
├── src/
|
|
│ ├── main/
|
|
│ │ └── java/
|
|
│ │ └── com/example/
|
|
│ │ ├── domain/ # Domain layer
|
|
│ │ ├── application/ # Application layer
|
|
│ │ ├── infrastructure/ # Infrastructure layer
|
|
│ │ └── shared/ # Shared utilities
|
|
│ └── test/
|
|
│ └── java/
|
|
│ └── com/example/
|
|
│ ├── domain/
|
|
│ ├── application/
|
|
│ ├── infrastructure/
|
|
│ └── shared/
|
|
├── README.md
|
|
└── .gitignore
|
|
```
|
|
|
|
### Maven Dependencies Structure
|
|
|
|
```xml
|
|
<project>
|
|
<modelVersion>4.0.0</modelVersion>
|
|
<groupId>com.example</groupId>
|
|
<artifactId>banking-system</artifactId>
|
|
<version>1.0.0</version>
|
|
|
|
<properties>
|
|
<maven.compiler.release>21</maven.compiler.release>
|
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
|
</properties>
|
|
|
|
<dependencies>
|
|
<!-- Java 21 features (no additional dependencies needed) -->
|
|
|
|
<!-- Testing -->
|
|
<dependency>
|
|
<groupId>junit</groupId>
|
|
<artifactId>junit-jupiter</artifactId>
|
|
<version>5.10.0</version>
|
|
<scope>test</scope>
|
|
</dependency>
|
|
|
|
<!-- Logging (infrastructure layer only) -->
|
|
<dependency>
|
|
<groupId>org.slf4j</groupId>
|
|
<artifactId>slf4j-api</artifactId>
|
|
<version>2.0.7</version>
|
|
</dependency>
|
|
|
|
<!-- Database (infrastructure only) -->
|
|
<dependency>
|
|
<groupId>org.postgresql</groupId>
|
|
<artifactId>postgresql</artifactId>
|
|
<version>42.6.0</version>
|
|
</dependency>
|
|
</dependencies>
|
|
|
|
<build>
|
|
<plugins>
|
|
<plugin>
|
|
<groupId>org.apache.maven.plugins</groupId>
|
|
<artifactId>maven-compiler-plugin</artifactId>
|
|
<version>3.11.0</version>
|
|
<configuration>
|
|
<release>21</release>
|
|
</configuration>
|
|
</plugin>
|
|
</plugins>
|
|
</build>
|
|
</project>
|
|
```
|
|
|
|
## Gradle Structure
|
|
|
|
Gradle-based alternative:
|
|
|
|
```gradle
|
|
plugins {
|
|
id 'java'
|
|
}
|
|
|
|
java {
|
|
sourceCompatibility = '21'
|
|
targetCompatibility = '21'
|
|
}
|
|
|
|
repositories {
|
|
mavenCentral()
|
|
}
|
|
|
|
dependencies {
|
|
// Testing
|
|
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
|
|
|
|
// Logging
|
|
implementation 'org.slf4j:slf4j-api:2.0.7'
|
|
runtimeOnly 'org.slf4j:slf4j-simple:2.0.7'
|
|
|
|
// Database
|
|
implementation 'org.postgresql:postgresql:42.6.0'
|
|
}
|
|
|
|
test {
|
|
useJUnitPlatform()
|
|
}
|
|
```
|
|
|
|
## Complete Directory Structure
|
|
|
|
### Domain Layer
|
|
|
|
```
|
|
src/main/java/com/example/domain/
|
|
├── account/ # Bounded context: Account
|
|
│ ├── Account.java # Aggregate root
|
|
│ ├── AccountId.java # Value object (ID)
|
|
│ ├── AccountStatus.java # Value object (enum-like)
|
|
│ ├── Money.java # Value object (shared across contexts)
|
|
│ ├── AccountError.java # Sealed interface for errors
|
|
│ ├── AccountRepository.java # Repository interface (domain contract)
|
|
│ └── DomainEventPublisher.java # Event publishing interface (optional)
|
|
│
|
|
├── transfer/ # Bounded context: Transfer
|
|
│ ├── Transfer.java # Aggregate root
|
|
│ ├── TransferId.java # Value object
|
|
│ ├── TransferStatus.java # Status enum
|
|
│ ├── TransferError.java # Errors
|
|
│ ├── TransferRepository.java # Repository interface
|
|
│ └── TransferService.java # Domain service (if needed)
|
|
│
|
|
├── shared/
|
|
│ ├── result/
|
|
│ │ ├── Result.java # Result<E, T> interface
|
|
│ │ ├── Success.java # Success case
|
|
│ │ └── Failure.java # Failure case
|
|
│ └── DomainEvent.java # Base domain event
|
|
```
|
|
|
|
### Application Layer
|
|
|
|
```
|
|
src/main/java/com/example/application/
|
|
├── account/
|
|
│ ├── OpenAccountUseCase.java # One use case per file
|
|
│ ├── DepositMoneyUseCase.java
|
|
│ ├── WithdrawMoneyUseCase.java
|
|
│ ├── GetAccountBalanceUseCase.java
|
|
│ ├── dto/
|
|
│ │ ├── OpenAccountRequest.java
|
|
│ │ ├── OpenAccountResponse.java
|
|
│ │ ├── DepositRequest.java
|
|
│ │ └── DepositResponse.java
|
|
│ └── AccountApplicationError.java # App-specific errors
|
|
│
|
|
├── transfer/
|
|
│ ├── TransferMoneyUseCase.java
|
|
│ ├── GetTransferStatusUseCase.java
|
|
│ ├── dto/
|
|
│ │ ├── TransferRequest.java
|
|
│ │ └── TransferResponse.java
|
|
│ └── TransferApplicationError.java
|
|
│
|
|
├── shared/
|
|
│ ├── UseCase.java # Interface/base class for use cases
|
|
│ └── UnitOfWork.java # Transaction management interface
|
|
```
|
|
|
|
### Infrastructure Layer
|
|
|
|
```
|
|
src/main/java/com/example/infrastructure/
|
|
├── persistence/
|
|
│ ├── account/
|
|
│ │ ├── JdbcAccountRepository.java # Implements AccountRepository
|
|
│ │ ├── AccountRowMapper.java # Database row mapping
|
|
│ │ └── AccountQueries.java # SQL constants
|
|
│ ├── transfer/
|
|
│ │ ├── JdbcTransferRepository.java
|
|
│ │ └── TransferRowMapper.java
|
|
│ ├── connection/
|
|
│ │ ├── ConnectionPool.java
|
|
│ │ └── DataSourceFactory.java
|
|
│ └── transaction/
|
|
│ └── JdbcUnitOfWork.java # Transaction coordinator
|
|
│
|
|
├── http/
|
|
│ ├── handler/
|
|
│ │ ├── account/
|
|
│ │ │ ├── OpenAccountHandler.java
|
|
│ │ │ ├── WithdrawHandler.java
|
|
│ │ │ └── GetBalanceHandler.java
|
|
│ │ └── transfer/
|
|
│ │ ├── TransferHandler.java
|
|
│ │ └── GetTransferStatusHandler.java
|
|
│ ├── router/
|
|
│ │ └── ApiRouter.java # Route definition
|
|
│ ├── response/
|
|
│ │ ├── SuccessResponse.java
|
|
│ │ └── ErrorResponse.java
|
|
│ └── middleware/
|
|
│ ├── ErrorHandlingMiddleware.java
|
|
│ └── LoggingMiddleware.java
|
|
│
|
|
├── event/
|
|
│ ├── DomainEventPublisherImpl.java # Publishes domain events
|
|
│ ├── event-handlers/
|
|
│ │ ├── AccountCreatedEventHandler.java
|
|
│ │ └── TransferCompletedEventHandler.java
|
|
│ └── EventDispatcher.java
|
|
│
|
|
├── config/
|
|
│ └── AppConfiguration.java # Dependency injection setup
|
|
│
|
|
└── persistence/
|
|
└── migrations/
|
|
├── V001__CreateAccountsTable.sql
|
|
└── V002__CreateTransfersTable.sql
|
|
```
|
|
|
|
### Test Structure
|
|
|
|
```
|
|
src/test/java/com/example/
|
|
├── domain/
|
|
│ ├── account/
|
|
│ │ ├── AccountTest.java # Unit tests for Account
|
|
│ │ ├── MoneyTest.java
|
|
│ │ └── AccountRepositoryTest.java # Contract tests
|
|
│ └── transfer/
|
|
│ ├── TransferTest.java
|
|
│ └── TransferRepositoryTest.java
|
|
│
|
|
├── application/
|
|
│ ├── account/
|
|
│ │ ├── OpenAccountUseCaseTest.java
|
|
│ │ ├── WithdrawMoneyUseCaseTest.java
|
|
│ │ └── fixtures/
|
|
│ │ ├── AccountFixture.java # Test data builders
|
|
│ │ └── MoneyFixture.java
|
|
│ └── transfer/
|
|
│ └── TransferMoneyUseCaseTest.java
|
|
│
|
|
├── infrastructure/
|
|
│ ├── persistence/
|
|
│ │ ├── JdbcAccountRepositoryTest.java # Integration tests
|
|
│ │ └── JdbcTransferRepositoryTest.java
|
|
│ └── http/
|
|
│ └── OpenAccountHandlerTest.java
|
|
│
|
|
└── acceptance/
|
|
└── OpenAccountAcceptanceTest.java # End-to-end tests
|
|
```
|
|
|
|
## Three Organizational Approaches
|
|
|
|
### Approach 1: BC-First (Recommended for Most Projects)
|
|
|
|
Organize around Bounded Contexts:
|
|
|
|
```
|
|
src/main/java/com/example/
|
|
├── account/ # BC 1
|
|
│ ├── domain/
|
|
│ │ ├── Account.java
|
|
│ │ ├── AccountError.java
|
|
│ │ └── AccountRepository.java
|
|
│ ├── application/
|
|
│ │ ├── OpenAccountUseCase.java
|
|
│ │ └── dto/
|
|
│ └── infrastructure/
|
|
│ ├── persistence/
|
|
│ │ └── JdbcAccountRepository.java
|
|
│ └── http/
|
|
│ └── OpenAccountHandler.java
|
|
│
|
|
├── transfer/ # BC 2
|
|
│ ├── domain/
|
|
│ ├── application/
|
|
│ └── infrastructure/
|
|
│
|
|
└── shared/ # Shared across BCs
|
|
├── result/
|
|
│ └── Result.java
|
|
└── events/
|
|
```
|
|
|
|
**Pros:**
|
|
- Clear BC boundaries
|
|
- Easy to navigate between layers within a context
|
|
- Natural place for context-specific configuration
|
|
- Facilitates team ownership per BC
|
|
|
|
**Cons:**
|
|
- Duplication across contexts
|
|
- More code organization overhead
|
|
|
|
### Approach 2: Tech-First (Better for Microservices)
|
|
|
|
Organize by technical layer:
|
|
|
|
```
|
|
src/main/java/com/example/
|
|
├── domain/
|
|
│ ├── account/
|
|
│ │ ├── Account.java
|
|
│ │ ├── AccountRepository.java
|
|
│ │ └── AccountError.java
|
|
│ └── transfer/
|
|
│
|
|
├── application/
|
|
│ ├── account/
|
|
│ │ ├── OpenAccountUseCase.java
|
|
│ │ └── dto/
|
|
│ └── transfer/
|
|
│
|
|
├── infrastructure/
|
|
│ ├── persistence/
|
|
│ │ ├── account/
|
|
│ │ │ └── JdbcAccountRepository.java
|
|
│ │ └── transfer/
|
|
│ ├── http/
|
|
│ │ ├── account/
|
|
│ │ └── transfer/
|
|
│ └── config/
|
|
│
|
|
└── shared/
|
|
```
|
|
|
|
**Pros:**
|
|
- Clear layer separation
|
|
- Easy to review layer architecture
|
|
- Good for enforcing dependency rules
|
|
|
|
**Cons:**
|
|
- Scattered BC concepts across files
|
|
- Harder to find all code for one feature
|
|
|
|
### Approach 3: Hybrid (Best for Large Projects)
|
|
|
|
Combine both approaches strategically:
|
|
|
|
```
|
|
src/main/java/com/example/
|
|
├── domain/ # All domain objects, shared across project
|
|
│ ├── account/
|
|
│ ├── transfer/
|
|
│ └── shared/
|
|
│
|
|
├── application/ # All application services
|
|
│ ├── account/
|
|
│ └── transfer/
|
|
│
|
|
├── infrastructure/ # Infrastructure organized by BC
|
|
│ ├── account/
|
|
│ │ ├── persistence/
|
|
│ │ └── http/
|
|
│ ├── transfer/
|
|
│ ├── config/
|
|
│ └── shared/
|
|
```
|
|
|
|
**Pros:**
|
|
- Emphasizes domain independence
|
|
- Clear infrastructure layer separation
|
|
- Good for large teams
|
|
|
|
**Cons:**
|
|
- Two different organizational styles
|
|
- Requires discipline to maintain
|
|
|
|
## One Use Case Per File
|
|
|
|
### Recommended Structure
|
|
|
|
```
|
|
src/main/java/com/example/application/account/
|
|
├── OpenAccountUseCase.java
|
|
├── DepositMoneyUseCase.java
|
|
├── WithdrawMoneyUseCase.java
|
|
├── GetAccountBalanceUseCase.java
|
|
├── CloseAccountUseCase.java
|
|
└── dto/
|
|
├── OpenAccountRequest.java
|
|
├── OpenAccountResponse.java
|
|
├── DepositRequest.java
|
|
└── DepositResponse.java
|
|
```
|
|
|
|
### Use Case File Template
|
|
|
|
```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;
|
|
|
|
/**
|
|
* OpenAccountUseCase - one file, one use case.
|
|
*
|
|
* Coordinates the opening of a new account:
|
|
* 1. Create Account aggregate
|
|
* 2. Persist via repository
|
|
* 3. Publish domain events
|
|
*/
|
|
public class OpenAccountUseCase {
|
|
private final AccountRepository accountRepository;
|
|
private final AccountIdGenerator idGenerator;
|
|
private final UnitOfWork unitOfWork;
|
|
|
|
public OpenAccountUseCase(
|
|
AccountRepository accountRepository,
|
|
AccountIdGenerator idGenerator,
|
|
UnitOfWork unitOfWork
|
|
) {
|
|
this.accountRepository = accountRepository;
|
|
this.idGenerator = idGenerator;
|
|
this.unitOfWork = unitOfWork;
|
|
}
|
|
|
|
/**
|
|
* Execute the use case.
|
|
*
|
|
* @param request containing account opening parameters
|
|
* @return success with account ID, or failure with reason
|
|
*/
|
|
public Result<OpenAccountError, OpenAccountResponse> execute(
|
|
OpenAccountRequest request
|
|
) {
|
|
try {
|
|
// Phase 1: Create aggregate
|
|
AccountId accountId = idGenerator.generate();
|
|
Result<AccountError, Account> accountResult = Account.create(
|
|
accountId,
|
|
request.initialBalance(),
|
|
request.accountHolder()
|
|
);
|
|
|
|
if (accountResult instanceof Failure f) {
|
|
return failure(mapError(f.error()));
|
|
}
|
|
|
|
Account account = ((Success<AccountError, Account>) accountResult).value();
|
|
|
|
// Phase 2: Persist
|
|
return unitOfWork.withTransaction(() -> {
|
|
accountRepository.save(account);
|
|
|
|
// Phase 3: Publish events
|
|
account.publishedEvents().forEach(event ->
|
|
eventPublisher.publish(event)
|
|
);
|
|
|
|
return success(new OpenAccountResponse(
|
|
account.id().value(),
|
|
account.balance()
|
|
));
|
|
});
|
|
|
|
} catch (Exception e) {
|
|
logger.error("Unexpected error opening account", e);
|
|
return failure(new OpenAccountError.RepositoryError("Failed to save account"));
|
|
}
|
|
}
|
|
|
|
private OpenAccountError mapError(AccountError error) {
|
|
return switch (error) {
|
|
case InvalidAmountError e ->
|
|
new OpenAccountError.InvalidInitialBalance(e.message());
|
|
case InvalidAccountHolderError e ->
|
|
new OpenAccountError.InvalidHolder(e.message());
|
|
default ->
|
|
new OpenAccountError.UnexpectedError(error.message());
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* OpenAccountRequest - input DTO.
|
|
*/
|
|
public record OpenAccountRequest(
|
|
Money initialBalance,
|
|
String accountHolder
|
|
) {
|
|
public OpenAccountRequest {
|
|
if (initialBalance == null) {
|
|
throw new IllegalArgumentException("Initial balance required");
|
|
}
|
|
if (accountHolder == null || accountHolder.isBlank()) {
|
|
throw new IllegalArgumentException("Account holder name required");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* OpenAccountResponse - output DTO.
|
|
*/
|
|
public record OpenAccountResponse(
|
|
String accountId,
|
|
Money balance
|
|
) {}
|
|
|
|
/**
|
|
* OpenAccountError - use case specific errors.
|
|
*/
|
|
public sealed interface OpenAccountError permits
|
|
InvalidInitialBalance,
|
|
InvalidHolder,
|
|
RepositoryError,
|
|
UnexpectedError {
|
|
String message();
|
|
}
|
|
|
|
public record InvalidInitialBalance(String reason) implements OpenAccountError {
|
|
@Override
|
|
public String message() {
|
|
return "Invalid initial balance: " + reason;
|
|
}
|
|
}
|
|
|
|
// ... other error implementations
|
|
```
|
|
|
|
## Package Naming
|
|
|
|
```
|
|
com.example # Root
|
|
├── domain # Domain layer
|
|
│ ├── account # BC 1 domain
|
|
│ │ └── Account.java
|
|
│ └── transfer # BC 2 domain
|
|
│ └── Transfer.java
|
|
├── application # Application layer
|
|
│ ├── account # BC 1 use cases
|
|
│ │ └── OpenAccountUseCase.java
|
|
│ └── transfer # BC 2 use cases
|
|
│ └── TransferMoneyUseCase.java
|
|
├── infrastructure # Infrastructure layer
|
|
│ ├── persistence # Persistence adapters
|
|
│ │ ├── account # BC 1 persistence
|
|
│ │ └── transfer # BC 2 persistence
|
|
│ ├── http # HTTP adapters
|
|
│ │ ├── account # BC 1 handlers
|
|
│ │ └── transfer # BC 2 handlers
|
|
│ └── config # Configuration
|
|
│ └── AppConfiguration.java
|
|
└── shared # Shared across layers
|
|
├── result # Result<E, T>
|
|
├── events # Domain events
|
|
└── exceptions # Shared exceptions (use sparingly)
|
|
```
|
|
|
|
## Example: Account BC Structure
|
|
|
|
Complete example of one bounded context:
|
|
|
|
```
|
|
com/example/account/
|
|
├── domain/
|
|
│ ├── Account.java # Aggregate root
|
|
│ ├── AccountId.java # ID value object
|
|
│ ├── AccountStatus.java # Status value object
|
|
│ ├── AccountError.java # Sealed error interface
|
|
│ ├── AccountRepository.java # Repository interface
|
|
│ ├── DomainEvents.java # Domain events (AccountCreated, etc.)
|
|
│ └── AccountIdGenerator.java # Generator interface
|
|
│
|
|
├── application/
|
|
│ ├── OpenAccountUseCase.java
|
|
│ ├── DepositMoneyUseCase.java
|
|
│ ├── WithdrawMoneyUseCase.java
|
|
│ ├── GetAccountBalanceUseCase.java
|
|
│ ├── ApplicationError.java # App-level errors
|
|
│ ├── dto/
|
|
│ │ ├── OpenAccountRequest.java
|
|
│ │ ├── OpenAccountResponse.java
|
|
│ │ ├── DepositRequest.java
|
|
│ │ └── DepositResponse.java
|
|
│ └── fixtures/ (test directory)
|
|
│ └── AccountFixture.java
|
|
│
|
|
└── infrastructure/
|
|
├── persistence/
|
|
│ ├── JdbcAccountRepository.java
|
|
│ ├── AccountRowMapper.java
|
|
│ └── AccountQueries.java
|
|
├── http/
|
|
│ ├── OpenAccountHandler.java
|
|
│ ├── DepositHandler.java
|
|
│ ├── WithdrawHandler.java
|
|
│ └── GetBalanceHandler.java
|
|
└── events/
|
|
└── AccountEventHandlers.java
|
|
```
|
|
|
|
## Configuration Example
|
|
|
|
```java
|
|
package com.example.infrastructure.config;
|
|
|
|
public class AppConfiguration {
|
|
private final DataSource dataSource;
|
|
private final DomainEventPublisher eventPublisher;
|
|
|
|
public AppConfiguration() {
|
|
this.dataSource = createDataSource();
|
|
this.eventPublisher = createEventPublisher();
|
|
}
|
|
|
|
// Account BC dependencies
|
|
public AccountRepository accountRepository() {
|
|
return new JdbcAccountRepository(dataSource);
|
|
}
|
|
|
|
public AccountIdGenerator accountIdGenerator() {
|
|
return new UuidAccountIdGenerator();
|
|
}
|
|
|
|
public OpenAccountUseCase openAccountUseCase() {
|
|
return new OpenAccountUseCase(
|
|
accountRepository(),
|
|
accountIdGenerator(),
|
|
unitOfWork()
|
|
);
|
|
}
|
|
|
|
// Transfer BC dependencies
|
|
public TransferRepository transferRepository() {
|
|
return new JdbcTransferRepository(dataSource);
|
|
}
|
|
|
|
public TransferMoneyUseCase transferMoneyUseCase() {
|
|
return new TransferMoneyUseCase(
|
|
accountRepository(),
|
|
transferRepository(),
|
|
unitOfWork()
|
|
);
|
|
}
|
|
|
|
// Shared infrastructure
|
|
public UnitOfWork unitOfWork() {
|
|
return new JdbcUnitOfWork(dataSource);
|
|
}
|
|
|
|
public DomainEventPublisher eventPublisher() {
|
|
return eventPublisher;
|
|
}
|
|
|
|
private DataSource createDataSource() {
|
|
// Database pool configuration
|
|
return new HikariDataSource();
|
|
}
|
|
|
|
private DomainEventPublisher createEventPublisher() {
|
|
return new SimpleEventPublisher();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Organize by BC first** when starting a project
|
|
2. **One use case per file** in application layer
|
|
3. **Keep test directory structure** parallel to main
|
|
4. **Place DTOs near their use cases** (not in separate folder)
|
|
5. **Shared code in `shared` package** (Result, base classes)
|
|
6. **Database migrations** in dedicated folder
|
|
7. **Configuration at root** of infrastructure layer
|
|
8. **HTTP handlers** group by BC
|
|
9. **Repository implementations** group by BC
|
|
10. **No circular package dependencies** - enforce with checkstyle
|