209 lines
6.8 KiB
Markdown
209 lines
6.8 KiB
Markdown
# Domain-Driven Design (DDD) in Go
|
|
|
|
## When to Use
|
|
|
|
Apply DDD when the business domain is complex enough that the code structure should mirror the business model — typically services with 5K+ lines, multiple bounded contexts, or rich business rules. Do NOT use for simple CRUD apps or CLI tools.
|
|
|
|
## Building Blocks
|
|
|
|
| Concept | Go Mapping | Purpose |
|
|
| --- | --- | --- |
|
|
| **Entity** | Struct with identity field | Has unique ID, mutable state, lifecycle |
|
|
| **Value Object** | Immutable struct, compared by value | No identity — represents a measurement, quantity, or descriptor |
|
|
| **Aggregate** | Entity + child entities/value objects | Consistency boundary — all mutations go through the root |
|
|
| **Repository** | Interface in domain, impl in infrastructure | Persistence abstraction for aggregates |
|
|
| **Domain Service** | Function or struct in domain package | Logic that spans multiple aggregates |
|
|
| **Domain Event** | Struct describing a fact that happened | Decouples bounded contexts |
|
|
|
|
## Project Structure
|
|
|
|
Organize by **bounded context**, grouping domain, application, and adapters vertically. This scales across multiple contexts and clarifies ownership.
|
|
|
|
```
|
|
order-service/
|
|
├── cmd/
|
|
│ └── server/
|
|
│ └── main.go # Wiring only
|
|
├── internal/
|
|
│ ├── order/ # Bounded context: Order
|
|
│ │ ├── domain/
|
|
│ │ │ ├── order.go # Order aggregate root
|
|
│ │ │ ├── item.go # OrderItem entity
|
|
│ │ │ ├── status.go # OrderStatus enum
|
|
│ │ │ ├── repository.go # OrderRepository interface
|
|
│ │ │ └── events.go # OrderPlaced, OrderShipped events
|
|
│ │ ├── application/
|
|
│ │ │ ├── place_order.go # PlaceOrderHandler (command)
|
|
│ │ │ └── get_order.go # GetOrderHandler (query)
|
|
│ │ └── adapters/
|
|
│ │ ├── persistence/
|
|
│ │ │ └── postgres.go # OrderRepository implementation
|
|
│ │ └── http/
|
|
│ │ └── handler.go # HTTP transport
|
|
│ ├── billing/ # Bounded context: Billing (another example)
|
|
│ │ ├── domain/
|
|
│ │ ├── application/
|
|
│ │ └── adapters/
|
|
│ ├── shared/
|
|
│ │ └── money.go # Value object reused across contexts
|
|
│ └── events/
|
|
│ └── publisher.go # Shared event bus (infrastructure)
|
|
├── go.mod
|
|
└── go.sum
|
|
```
|
|
|
|
**Key principles:**
|
|
|
|
- Group each bounded context **vertically** (domain → application → adapters), not by technical role
|
|
- Use `adapters/` instead of `infrastructure/` to be explicit about Hexagonal Architecture
|
|
- Make cross-context boundaries explicit (see **Bounded Contexts** section below)
|
|
- Place shared infrastructure (event bus, logging) at `internal/{shared}/` or `internal/events/`
|
|
|
|
## Code Examples
|
|
|
|
### Value Object — Money
|
|
|
|
```go
|
|
// internal/domain/shared/money.go
|
|
package shared
|
|
|
|
type Money struct {
|
|
amount int64 // cents — avoids float precision issues
|
|
currency string
|
|
}
|
|
|
|
func NewMoney(amount int64, currency string) (Money, error) {
|
|
if currency == "" {
|
|
return Money{}, errors.New("currency is required")
|
|
}
|
|
return Money{amount: amount, currency: currency}, nil
|
|
}
|
|
|
|
func (m Money) Add(other Money) (Money, error) {
|
|
if m.currency != other.currency {
|
|
return Money{}, fmt.Errorf("cannot add %s to %s", other.currency, m.currency)
|
|
}
|
|
return Money{amount: m.amount + other.amount, currency: m.currency}, nil
|
|
}
|
|
```
|
|
|
|
### Aggregate Root — Order
|
|
|
|
```go
|
|
// internal/domain/order/order.go
|
|
package order
|
|
|
|
type Order struct {
|
|
id string
|
|
items []Item
|
|
status Status
|
|
total shared.Money
|
|
}
|
|
|
|
func NewOrder(id string) *Order {
|
|
return &Order{id: id, status: StatusDraft}
|
|
}
|
|
|
|
// All mutations go through the aggregate root
|
|
func (o *Order) AddItem(item Item) error {
|
|
if o.status != StatusDraft {
|
|
return ErrOrderNotEditable
|
|
}
|
|
o.items = append(o.items, item)
|
|
return o.recalculateTotal()
|
|
}
|
|
|
|
func (o *Order) Place() (OrderPlaced, error) {
|
|
if len(o.items) == 0 {
|
|
return OrderPlaced{}, ErrEmptyOrder
|
|
}
|
|
o.status = StatusPlaced
|
|
return OrderPlaced{OrderID: o.id, Total: o.total}, nil
|
|
}
|
|
```
|
|
|
|
### Repository Interface — defined in domain
|
|
|
|
```go
|
|
// internal/order/domain/repository.go
|
|
package domain
|
|
|
|
type Repository interface {
|
|
Save(ctx context.Context, order *Order) error
|
|
FindByID(ctx context.Context, id string) (*Order, error)
|
|
}
|
|
```
|
|
|
|
The implementation lives in `internal/order/adapters/persistence/postgres.go` and depends on the domain — never the reverse.
|
|
|
|
### Application Service — orchestrates a use case
|
|
|
|
```go
|
|
// internal/order/application/place_order.go
|
|
package application
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"myapp/internal/order/domain"
|
|
)
|
|
|
|
type PlaceOrderHandler struct {
|
|
orders domain.Repository
|
|
events EventPublisher
|
|
}
|
|
|
|
func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) error {
|
|
order, err := h.orders.FindByID(ctx, cmd.OrderID)
|
|
if err != nil {
|
|
return fmt.Errorf("finding order: %w", err)
|
|
}
|
|
|
|
evt, err := order.Place()
|
|
if err != nil {
|
|
return fmt.Errorf("placing order: %w", err)
|
|
}
|
|
|
|
if err := h.orders.Save(ctx, order); err != nil {
|
|
return fmt.Errorf("saving order: %w", err)
|
|
}
|
|
|
|
return h.events.Publish(ctx, evt)
|
|
}
|
|
```
|
|
|
|
## Bounded Contexts
|
|
|
|
Each bounded context maps to a top-level package under `internal/` with its own domain, application, and adapters. Contexts communicate through domain events or explicit anti-corruption layers — never by importing each other's internal types directly.
|
|
|
|
**Anti-corruption layer example:** If `billing/` needs to consume an `order.OrderPlaced` event, translate it to a billing-specific type:
|
|
|
|
```go
|
|
// internal/billing/adapters/events/order_events.go
|
|
package events
|
|
|
|
import (
|
|
"myapp/internal/events"
|
|
"myapp/internal/billing/domain"
|
|
)
|
|
|
|
type OrderPlacedSubscriber struct {
|
|
invoices domain.InvoiceRepository
|
|
}
|
|
|
|
// Receives order.OrderPlaced, translates to billing domain
|
|
func (s *OrderPlacedSubscriber) OnOrderPlaced(evt events.OrderPlaced) error {
|
|
// Translate and create invoice
|
|
return s.invoices.Create(evt.OrderID, evt.Total)
|
|
}
|
|
```
|
|
|
|
This prevents billing from depending on order's internal types.
|
|
|
|
For large systems, each context can be its own Go module in a workspace (`go.work`). See the `samber/cc-skills-golang@golang-project-layout` skill for workspace setup.
|
|
|
|
## Wiring
|
|
|
|
Wire dependencies in `cmd/server/main.go` using manual constructor injection. → See `samber/cc-skills-golang@golang-dependency-injection` skill for DI library alternatives.
|