1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 15:59:35 +01:00

docs: and skills

This commit is contained in:
Sebastian Frick 2026-02-18 23:25:12 +01:00
parent e4f0665086
commit ccd4ee534a
25 changed files with 10412 additions and 0 deletions

View file

@ -0,0 +1,265 @@
# 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)
// ...
}
```

View file

@ -0,0 +1,184 @@
# DDD Rules Checklist
Use this checklist to validate your domain model against DDD principles.
## Aggregate Rules
### Boundaries
- [ ] Aggregate has a clearly defined boundary
- [ ] Aggregate Root is identified and documented
- [ ] Only Aggregate Root is accessible from outside
- [ ] Child entities are encapsulated (not exported or accessed directly)
- [ ] Aggregate is the smallest consistent unit
### Invariants
- [ ] All invariants are documented with `// Invariant:` comments
- [ ] Constructor validates all invariants
- [ ] Every mutation method maintains invariants
- [ ] Invalid state is impossible to create
- [ ] Aggregate rejects invalid operations (returns error)
### References
- [ ] No direct object references to other Aggregates
- [ ] References to other Aggregates use ID only
- [ ] ID references are typed (not raw strings/ints)
- [ ] Cross-aggregate consistency is eventual (not immediate)
### Transactions
- [ ] One Aggregate = one transaction boundary
- [ ] No multi-aggregate transactions in domain layer
- [ ] Application layer coordinates multiple aggregates if needed
### Sizing
- [ ] Aggregate is not too large (performance issues)
- [ ] Aggregate is not too small (consistency issues)
- [ ] "Just right" - protects business invariants, nothing more
---
## Entity Rules
### Identity
- [ ] Has a unique identifier
- [ ] ID is assigned at creation time
- [ ] ID is immutable after creation
- [ ] ID type is specific (e.g., `AccountID`, not `string`)
### Equality
- [ ] Equals compares by ID only
- [ ] Two entities with same ID are considered equal
- [ ] Attribute changes don't affect equality
### Mutability
- [ ] State changes through explicit methods
- [ ] No public setters
- [ ] Methods express domain operations (not CRUD)
- [ ] Methods return errors for invalid operations
---
## Value Object Rules
### Immutability
- [ ] All fields are private/unexported
- [ ] No setter methods
- [ ] No methods that modify internal state
- [ ] "Modification" creates new instance
### Equality
- [ ] Equals compares ALL fields
- [ ] Two VOs with same values are interchangeable
- [ ] No identity concept
### Validation
- [ ] Constructor validates input
- [ ] Invalid VOs cannot be created
- [ ] Returns error for invalid input
- [ ] Provides `MustXxx()` variant for tests (panics on error)
### Self-Containment
- [ ] VO contains all related logic
- [ ] Domain logic lives in VO methods
- [ ] Example: `Money.Add(other Money) Money`
---
## Repository Rules
### Interface Location
- [ ] Repository interface defined in domain layer
- [ ] Implementation in infrastructure layer
- [ ] Domain doesn't know about infrastructure
### Methods
- [ ] Methods operate on Aggregates (not entities)
- [ ] `Save(aggregate)` - persists aggregate
- [ ] `FindByID(id)` - retrieves aggregate
- [ ] No methods that bypass Aggregate Root
### Transactions
- [ ] Repository doesn't manage transactions
- [ ] Application layer manages transaction scope
- [ ] One repository call = one aggregate
---
## Domain Service Rules
### When to Use
- [ ] Operation spans multiple aggregates
- [ ] Operation doesn't naturally belong to any entity/VO
- [ ] Significant domain logic that's not entity behavior
### Implementation
- [ ] Stateless (no instance variables)
- [ ] Named after domain operation (e.g., `TransferService`)
- [ ] Injected dependencies via constructor
- [ ] Returns domain objects, not DTOs
---
## Domain Event Rules
### Naming
- [ ] Past tense (e.g., `AccountCreated`, `TransferCompleted`)
- [ ] Describes what happened, not what to do
- [ ] Domain language, not technical terms
### Content
- [ ] Contains all data needed by handlers
- [ ] Immutable after creation
- [ ] Includes aggregate ID and timestamp
### Publishing
- [ ] Events raised by Aggregates
- [ ] Published after aggregate is saved
- [ ] Handlers in application or infrastructure layer
---
## Clean Architecture Rules
### Dependency Direction
- [ ] Domain layer has no external dependencies
- [ ] Application layer depends only on Domain
- [ ] Infrastructure depends on Domain and Application
- [ ] No circular dependencies
### Layer Contents
- [ ] Domain: Entities, VOs, Aggregates, Repository interfaces, Domain Services
- [ ] Application: Use Cases, Application Services, DTOs
- [ ] Infrastructure: Repository implementations, External services, Framework code
### Interface Segregation
- [ ] Small, focused interfaces
- [ ] Defined by consumer (domain layer)
- [ ] Implemented by provider (infrastructure)
---
## Common Anti-Patterns to Avoid
### Anemic Domain Model
- ❌ Entities with only getters/setters
- ❌ All logic in services
- ✅ Rich domain model with behavior
### Large Aggregates
- ❌ Loading entire object graph
- ❌ Too many entities in one aggregate
- ✅ Small, focused aggregates
### Aggregate References
- ❌ `order.Customer` (direct reference)
- ✅ `order.CustomerID` (ID reference)
### Business Logic Leakage
- ❌ Validation in controllers
- ❌ Business rules in repositories
- ✅ All business logic in domain layer
### Technical Concepts in Domain
- ❌ `@Entity`, `@Column` annotations
- ❌ JSON serialization tags
- ✅ Pure domain objects, mapping in infrastructure

View file

@ -0,0 +1,178 @@
# Degraded State Pattern
## Problem
When domain invariants evolve in production systems, old data may violate new business rules:
```java
// Version 1: Email optional
public class Customer {
private EmailAddress email; // nullable
}
// Version 2: Email becomes required (new invariant)
public static Result<CustomerError, Customer> create(...) {
if (email == null) {
return Result.failure(new EmailRequiredError()); // ❌ Old data breaks!
}
}
```
**Without a migration strategy, existing production data cannot be loaded.**
## Solution: Degraded State Pattern
Allow entities to exist in a "degraded state" where some invariants are temporarily violated. The entity:
1. **Can be loaded** from persistence despite missing new required fields
2. **Is flagged** as degraded with clear indication of missing fields
3. **Blocks certain operations** until brought to valid state
4. **Provides path to recovery** through explicit update operations
## When to Use
**Use when**:
- Adding new required fields to existing entities
- Tightening validation rules on production data
- Migrating data that doesn't meet current standards
- Gradual rollout of stricter business rules
**Don't use when**:
- Creating new entities (always enforce current invariants)
- Data corruption (fix at persistence layer instead)
- Temporary technical failures (use retry/circuit breaker instead)
## Implementation Pattern
### 1. Dual Factory Methods
```java
public class Customer {
private final CustomerId id;
private final String name;
private EmailAddress email; // New required field
private final boolean isDegraded;
/**
* Creates NEW customer (strict validation).
* Enforces all current invariants.
*/
public static Result<CustomerError, Customer> create(
CustomerId id,
String name,
EmailAddress email
) {
// Strict validation
if (email == null) {
return Result.failure(new EmailRequiredError(
"Email is required for new customers"
));
}
return Result.success(new Customer(id, name, email, false));
}
/**
* Reconstructs customer from persistence (lenient).
* Allows loading old data that doesn't meet current invariants.
*/
public static Customer fromPersistence(
CustomerId id,
String name,
EmailAddress email // Can be null for old data
) {
boolean isDegraded = (email == null);
if (isDegraded) {
log.warn("Customer loaded in degraded state: id={}, missing=email", id);
}
return new Customer(id, name, email, isDegraded);
}
private Customer(CustomerId id, String name, EmailAddress email, boolean isDegraded) {
this.id = id;
this.name = name;
this.email = email;
this.isDegraded = isDegraded;
}
}
```
### 2. Operation Gating
Block operations that require valid state:
```java
public Result<CustomerError, Order> placeOrder(OrderDetails details) {
// Guard: Cannot place order in degraded state
if (isDegraded) {
return Result.failure(new CustomerDegradedError(
"Please complete your profile (add email) before placing orders",
List.of("email")
));
}
// Normal business logic
Order order = new Order(details, this.email);
return Result.success(order);
}
```
### 3. Recovery Path
Provide explicit operations to exit degraded state:
```java
public Result<CustomerError, Void> updateEmail(EmailAddress newEmail) {
if (newEmail == null) {
return Result.failure(new EmailRequiredError("Email cannot be null"));
}
this.email = newEmail;
this.isDegraded = false; // Exit degraded state
log.info("Customer email updated, exited degraded state: id={}", id);
return Result.success(null);
}
public boolean isDegraded() {
return isDegraded;
}
public List<String> getMissingFields() {
List<String> missing = new ArrayList<>();
if (email == null) {
missing.add("email");
}
return missing;
}
```
## Best Practices
**Do**:
- Use dual factory methods (`create()` strict, `fromPersistence()` lenient)
- Log when entities load in degraded state
- Provide clear error messages with missing fields
- Allow read operations and deposits (recovery paths)
- Block critical operations until valid
- Provide explicit recovery operations
- Monitor degraded entity count in production
**Don't**:
- Allow new entities to be created in degraded state
- Silently accept degraded state without logging
- Block all operations (allow recovery paths)
- Forget to provide user-facing recovery UI
- Leave entities degraded indefinitely (migrate!)
- Use degraded state for temporary failures
## Summary
The Degraded State Pattern enables:
- ✅ **Zero-downtime schema evolution**
- ✅ **Gradual migration of invariants**
- ✅ **Clear user communication** about incomplete data
- ✅ **Explicit recovery paths** to valid state
- ✅ **Production safety** during schema changes
Use it when domain rules evolve and existing production data doesn't meet new standards.

View file

@ -0,0 +1,47 @@
# Error Handling in DDD
This document defines error handling principles that apply across all layers and languages.
## Core Principles
1. **No Silent Failures** - All errors must be explicitly handled or propagated
2. **Layer-Specific Errors** - Each layer defines its own error types
3. **Error Transformation** - Errors are transformed at layer boundaries
4. **Proper Logging** - Original errors logged before transformation
5. **Result Types Over Exceptions** - Prefer Result types (where supported) over exceptions
## Language-Specific Implementation
- **Java**: See [languages/java/error-handling.md](../languages/java/error-handling.md)
- **Go**: Use error returns and custom error types
## Error Hierarchy
```
Domain Errors (business rule violations)
↓ transformed at boundary
Application Errors (use case failures)
↓ transformed at boundary
Infrastructure Errors (technical failures)
```
## Logging Strategy
- **ERROR**: Infrastructure failures (database down, network error)
- **WARN**: Business rule violations (insufficient funds, invalid state)
- **INFO**: Normal operations (order placed, account created)
- **DEBUG**: Detailed flow information
- **TRACE**: Original errors when transforming between layers
## Exception Boundary
**Infrastructure Layer** is the exception boundary:
- Infrastructure catches external exceptions (SQL, HTTP, etc.)
- Transforms to domain error types
- Logs original exception at ERROR level
- Returns domain error type
**Domain and Application** layers:
- Never throw exceptions (use Result types or errors)
- Only work with domain/application error types
- No try/catch blocks needed

View file

@ -0,0 +1,282 @@
# Guide to Defining Invariants
Invariants are business rules that must ALWAYS be true for an aggregate. They define the consistency boundaries of your domain.
## What is an Invariant?
An invariant is a condition that must hold true at all times for an aggregate to be in a valid state.
**Characteristics**:
- Must be true before AND after every operation
- Cannot be temporarily violated
- Enforced within the aggregate boundary
- Violation results in error (operation rejected)
## Identifying Invariants
### Questions to Ask
1. **"What would break the business if violated?"**
- Example: Negative account balance for non-credit accounts
2. **"What rules must ALWAYS hold?"**
- Example: Order total = sum of line items
3. **"What conditions make an operation invalid?"**
- Example: Cannot ship order that's not paid
4. **"What relationships must be maintained?"**
- Example: Account must have at least one owner
5. **"What limits exist?"**
- Example: Maximum 10 items per order
### Common Invariant Categories
| Category | Example |
|----------|---------|
| **Range constraints** | Balance >= 0, Quantity > 0 |
| **Required relationships** | Order must have at least one line item |
| **State transitions** | Cannot cancel shipped order |
| **Uniqueness** | No duplicate line items for same product |
| **Consistency** | Sum of parts equals total |
| **Business limits** | Max withdrawal per day |
## Documenting Invariants
### Comment Format
```go
// Invariant: <clear, concise description>
```
Place invariant comments:
1. At the struct definition (all invariants)
2. At methods that enforce specific invariants
### Example
```go
// Account represents a bank account aggregate.
// Invariants:
// - Balance cannot be negative for standard accounts
// - Account must have at least one holder with OWNER role
// - Frozen account cannot process debit operations
// - Credit limit cannot exceed maximum allowed for account type
type Account struct {
id AccountID
balance Money
holders []Holder
status Status
accountType AccountType
creditLimit Money
}
```
## Enforcing Invariants
### In Constructor
Validate all invariants at creation time:
```go
func NewAccount(id AccountID, holder Holder, accountType AccountType) (*Account, error) {
// Invariant: Account must have at least one holder with OWNER role
if holder.Role() != RoleOwner {
return nil, ErrFirstHolderMustBeOwner
}
// Invariant: Balance starts at zero (implicitly non-negative)
return &Account{
id: id,
balance: MustMoney(0),
holders: []Holder{holder},
status: StatusActive,
accountType: accountType,
creditLimit: MustMoney(0),
}, nil
}
```
### In Mutation Methods
Check invariants before AND after state changes:
```go
func (a *Account) Withdraw(amount Money) error {
// Invariant: Frozen account cannot process debit operations
if a.status == StatusFrozen {
return ErrAccountFrozen
}
// Invariant: Balance cannot be negative for standard accounts
newBalance, err := a.balance.Subtract(amount)
if err != nil {
return err
}
if a.accountType == AccountTypeStandard && newBalance.IsNegative() {
return ErrInsufficientFunds
}
// Invariant: Credit limit cannot be exceeded
if newBalance.IsNegative() && newBalance.Abs().GreaterThan(a.creditLimit) {
return ErrCreditLimitExceeded
}
a.balance = newBalance
return nil
}
```
### In Remove Operations
Ensure invariants aren't violated by removal:
```go
func (a *Account) RemoveHolder(holderID HolderID) error {
// Invariant: Account must have at least one holder with OWNER role
remainingOwners := 0
for _, h := range a.holders {
if h.ID() != holderID && h.Role() == RoleOwner {
remainingOwners++
}
}
if remainingOwners == 0 {
return ErrCannotRemoveLastOwner
}
// Remove holder
a.holders = removeHolder(a.holders, holderID)
return nil
}
```
## Invariant Patterns
### Pattern 1: Validate-Then-Mutate
```go
func (a *Aggregate) DoSomething(param Param) error {
// 1. Validate preconditions (invariants)
if err := a.validatePreconditions(param); err != nil {
return err
}
// 2. Perform mutation
a.applyChange(param)
// 3. Validate postconditions (should always pass if logic is correct)
// Usually implicit, but can be explicit for complex operations
return nil
}
```
### Pattern 2: State Machine Transitions
```go
func (o *Order) Ship() error {
// Invariant: Only paid orders can be shipped
if o.status != StatusPaid {
return &InvalidStateTransitionError{
From: o.status,
To: StatusShipped,
Reason: "order must be paid before shipping",
}
}
o.status = StatusShipped
o.shippedAt = time.Now()
return nil
}
```
### Pattern 3: Collection Invariants
```go
func (o *Order) AddLineItem(item LineItem) error {
// Invariant: No duplicate products in order
for _, existing := range o.lineItems {
if existing.ProductID() == item.ProductID() {
return ErrDuplicateProduct
}
}
// Invariant: Maximum 10 items per order
if len(o.lineItems) >= 10 {
return ErrMaxItemsExceeded
}
o.lineItems = append(o.lineItems, item)
// Invariant: Total must equal sum of line items (maintained automatically)
o.recalculateTotal()
return nil
}
```
## Error Types for Invariant Violations
Create specific error types for each invariant:
```go
// Domain errors for invariant violations
var (
ErrInsufficientFunds = errors.New("insufficient funds")
ErrAccountFrozen = errors.New("account is frozen")
ErrCannotRemoveLastOwner = errors.New("cannot remove last owner")
ErrCreditLimitExceeded = errors.New("credit limit exceeded")
)
// Or use typed errors with details
type InsufficientFundsError struct {
Requested Money
Available Money
}
func (e InsufficientFundsError) Error() string {
return fmt.Sprintf("insufficient funds: requested %s, available %s",
e.Requested, e.Available)
}
```
## Testing Invariants
Test both valid and invalid cases:
```go
func TestAccount_Withdraw_InsufficientFunds(t *testing.T) {
account := NewAccount(id, holder, AccountTypeStandard)
account.Deposit(MustMoney(100))
// Invariant violation: Balance cannot be negative
err := account.Withdraw(MustMoney(150))
assert.ErrorIs(t, err, ErrInsufficientFunds)
assert.Equal(t, MustMoney(100), account.Balance()) // Unchanged
}
func TestAccount_RemoveHolder_LastOwner(t *testing.T) {
account := NewAccount(id, owner, AccountTypeStandard)
// Invariant violation: Cannot remove last owner
err := account.RemoveHolder(owner.ID())
assert.ErrorIs(t, err, ErrCannotRemoveLastOwner)
assert.Len(t, account.Holders(), 1) // Unchanged
}
```
## Invariants Checklist
For each aggregate, verify:
- [ ] All invariants are documented
- [ ] Constructor enforces all creation-time invariants
- [ ] Each mutation method maintains all invariants
- [ ] Specific error types exist for each invariant violation
- [ ] Tests cover both valid operations and invariant violations
- [ ] Invalid state is impossible to reach through any sequence of operations