mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:40:18 +01:00
7.4 KiB
7.4 KiB
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/orinfrastructure/ - NO database packages, HTTP packages, or framework code
- NO struct tags (
json:,gorm:, etc.) - Pure Go, pure business logic
Example:
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:
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/andapplication/ - Contains all framework-specific code
- Handles mapping between domain and persistence models
Example:
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:
// 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
// ✅ 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 |
// 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)
// ...
}