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:
parent
e4f0665086
commit
ccd4ee534a
25 changed files with 10412 additions and 0 deletions
265
bin/.claude/skills/ddd-model/rules/clean-arch.md
Normal file
265
bin/.claude/skills/ddd-model/rules/clean-arch.md
Normal 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)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
184
bin/.claude/skills/ddd-model/rules/ddd-rules.md
Normal file
184
bin/.claude/skills/ddd-model/rules/ddd-rules.md
Normal 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
|
||||
178
bin/.claude/skills/ddd-model/rules/degraded-state-pattern.md
Normal file
178
bin/.claude/skills/ddd-model/rules/degraded-state-pattern.md
Normal 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.
|
||||
47
bin/.claude/skills/ddd-model/rules/error-handling.md
Normal file
47
bin/.claude/skills/ddd-model/rules/error-handling.md
Normal 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
|
||||
282
bin/.claude/skills/ddd-model/rules/invariants.md
Normal file
282
bin/.claude/skills/ddd-model/rules/invariants.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue