mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:40:18 +01:00
265 lines
7.4 KiB
Markdown
265 lines
7.4 KiB
Markdown
# Clean Architecture for Go DDD Projects
|
|
|
|
## Directory Structure
|
|
|
|
```
|
|
project-root/
|
|
├── cmd/
|
|
│ └── api/
|
|
│ └── main.go # Application entry point
|
|
├── internal/
|
|
│ ├── domain/ # CORE - No external dependencies
|
|
│ │ └── <bounded-context>/
|
|
│ │ ├── aggregate.go # Aggregate roots
|
|
│ │ ├── entity.go # Child entities
|
|
│ │ ├── value_objects.go # Value objects
|
|
│ │ ├── repository.go # Repository INTERFACES
|
|
│ │ ├── service.go # Domain services
|
|
│ │ ├── events.go # Domain events
|
|
│ │ └── errors.go # Domain-specific errors
|
|
│ │
|
|
│ ├── application/ # USE CASES - Depends on Domain
|
|
│ │ └── <bounded-context>/
|
|
│ │ ├── service.go # Application services
|
|
│ │ ├── commands.go # Command handlers (CQRS)
|
|
│ │ ├── queries.go # Query handlers (CQRS)
|
|
│ │ └── dto.go # Data Transfer Objects
|
|
│ │
|
|
│ └── infrastructure/ # ADAPTERS - Depends on Domain & App
|
|
│ ├── persistence/
|
|
│ │ └── <bounded-context>/
|
|
│ │ ├── postgres_repository.go
|
|
│ │ ├── memory_repository.go
|
|
│ │ └── models.go # DB models (separate from domain)
|
|
│ ├── messaging/
|
|
│ │ └── kafka_publisher.go
|
|
│ └── http/
|
|
│ ├── handlers/
|
|
│ │ └── <bounded-context>_handler.go
|
|
│ ├── middleware/
|
|
│ └── router.go
|
|
├── pkg/ # Shared utilities (if needed)
|
|
│ └── errors/
|
|
│ └── errors.go
|
|
└── go.mod
|
|
```
|
|
|
|
## Layer Responsibilities
|
|
|
|
### Domain Layer (`internal/domain/`)
|
|
|
|
**Purpose**: Core business logic, independent of all frameworks and infrastructure.
|
|
|
|
**Contains**:
|
|
- Aggregates, Entities, Value Objects
|
|
- Repository interfaces (not implementations!)
|
|
- Domain Services
|
|
- Domain Events
|
|
- Domain Errors
|
|
|
|
**Rules**:
|
|
- NO imports from `application/` or `infrastructure/`
|
|
- NO database packages, HTTP packages, or framework code
|
|
- NO struct tags (`json:`, `gorm:`, etc.)
|
|
- Pure Go, pure business logic
|
|
|
|
**Example**:
|
|
```go
|
|
package accounts
|
|
|
|
// Domain layer - NO external imports
|
|
|
|
type Account struct {
|
|
id AccountID
|
|
balance Money
|
|
status Status
|
|
}
|
|
|
|
// Repository interface - implementation is in infrastructure
|
|
type AccountRepository interface {
|
|
Save(ctx context.Context, account *Account) error
|
|
FindByID(ctx context.Context, id AccountID) (*Account, error)
|
|
}
|
|
```
|
|
|
|
### Application Layer (`internal/application/`)
|
|
|
|
**Purpose**: Orchestrate use cases, coordinate domain objects.
|
|
|
|
**Contains**:
|
|
- Application Services (Use Cases)
|
|
- Command/Query Handlers (CQRS)
|
|
- DTOs for input/output
|
|
- Transaction management
|
|
|
|
**Rules**:
|
|
- Imports from `domain/` only
|
|
- NO direct database access
|
|
- NO HTTP/transport concerns
|
|
- Coordinates domain objects, doesn't contain business logic
|
|
|
|
**Example**:
|
|
```go
|
|
package accounts
|
|
|
|
import (
|
|
"context"
|
|
"myapp/internal/domain/accounts"
|
|
)
|
|
|
|
type TransferService struct {
|
|
accountRepo accounts.AccountRepository
|
|
txManager TransactionManager
|
|
}
|
|
|
|
func (s *TransferService) Transfer(ctx context.Context, cmd TransferCommand) error {
|
|
return s.txManager.Execute(ctx, func(ctx context.Context) error {
|
|
from, err := s.accountRepo.FindByID(ctx, cmd.FromAccountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
to, err := s.accountRepo.FindByID(ctx, cmd.ToAccountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Domain logic in domain objects
|
|
if err := from.Withdraw(cmd.Amount); err != nil {
|
|
return err
|
|
}
|
|
if err := to.Deposit(cmd.Amount); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Coordinate persistence
|
|
if err := s.accountRepo.Save(ctx, from); err != nil {
|
|
return err
|
|
}
|
|
return s.accountRepo.Save(ctx, to)
|
|
})
|
|
}
|
|
```
|
|
|
|
### Infrastructure Layer (`internal/infrastructure/`)
|
|
|
|
**Purpose**: Implement interfaces defined in domain, connect to external systems.
|
|
|
|
**Contains**:
|
|
- Repository implementations (Postgres, Redis, etc.)
|
|
- Message queue publishers/consumers
|
|
- External API clients
|
|
- HTTP handlers
|
|
- Database models (separate from domain entities)
|
|
|
|
**Rules**:
|
|
- Implements interfaces from `domain/`
|
|
- Can import from `domain/` and `application/`
|
|
- Contains all framework-specific code
|
|
- Handles mapping between domain and persistence models
|
|
|
|
**Example**:
|
|
```go
|
|
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"myapp/internal/domain/accounts"
|
|
)
|
|
|
|
// Compile-time check
|
|
var _ accounts.AccountRepository = (*PostgresAccountRepository)(nil)
|
|
|
|
type PostgresAccountRepository struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func (r *PostgresAccountRepository) Save(ctx context.Context, account *accounts.Account) error {
|
|
// Map domain to DB model
|
|
model := toDBModel(account)
|
|
// Persist
|
|
_, err := r.db.ExecContext(ctx, "INSERT INTO accounts ...", ...)
|
|
return err
|
|
}
|
|
|
|
func (r *PostgresAccountRepository) FindByID(ctx context.Context, id accounts.AccountID) (*accounts.Account, error) {
|
|
var model accountDBModel
|
|
err := r.db.QueryRowContext(ctx, "SELECT ... WHERE id = $1", id.String()).Scan(...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Map DB model to domain
|
|
return toDomain(model), nil
|
|
}
|
|
```
|
|
|
|
## Dependency Injection
|
|
|
|
Wire dependencies at application startup:
|
|
|
|
```go
|
|
// cmd/api/main.go
|
|
func main() {
|
|
// Infrastructure
|
|
db := connectDB()
|
|
accountRepo := persistence.NewPostgresAccountRepository(db)
|
|
|
|
// Application
|
|
transferService := application.NewTransferService(accountRepo)
|
|
|
|
// HTTP
|
|
handler := http.NewAccountHandler(transferService)
|
|
|
|
// Start server
|
|
router := setupRouter(handler)
|
|
http.ListenAndServe(":8080", router)
|
|
}
|
|
```
|
|
|
|
## Package Naming Conventions
|
|
|
|
| Layer | Package Name | Example |
|
|
|-------|--------------|---------|
|
|
| Domain | Bounded context name | `accounts`, `transfers`, `loyalty` |
|
|
| Application | Same as domain | `accounts` (in `application/accounts/`) |
|
|
| Infrastructure | Descriptive | `persistence`, `messaging`, `http` |
|
|
|
|
## Import Rules
|
|
|
|
```go
|
|
// ✅ ALLOWED
|
|
domain/accounts -> (nothing external)
|
|
application/accounts -> domain/accounts
|
|
infrastructure/persistence -> domain/accounts, application/accounts
|
|
|
|
// ❌ NOT ALLOWED
|
|
domain/accounts -> application/accounts // Domain can't know application
|
|
domain/accounts -> infrastructure/ // Domain can't know infra
|
|
application/accounts -> infrastructure/ // App can't know infra
|
|
```
|
|
|
|
## Testing Strategy
|
|
|
|
| Layer | Test Type | Mocking |
|
|
|-------|-----------|---------|
|
|
| Domain | Unit tests | No mocks needed |
|
|
| Application | Unit tests | Mock repositories |
|
|
| Infrastructure | Integration tests | Real DB (testcontainers) |
|
|
| E2E | API tests | Full stack |
|
|
|
|
```go
|
|
// Domain test - no mocks
|
|
func TestAccount_Withdraw(t *testing.T) {
|
|
account := accounts.NewAccount(id, accounts.MustMoney(100))
|
|
err := account.Withdraw(accounts.MustMoney(50))
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, accounts.MustMoney(50), account.Balance())
|
|
}
|
|
|
|
// Application test - mock repository
|
|
func TestTransferService(t *testing.T) {
|
|
repo := &MockAccountRepository{}
|
|
service := NewTransferService(repo)
|
|
// ...
|
|
}
|
|
```
|