DDD patterns — Entity, Aggregate, Bounded Context — answer the question "what to build." This article is about something else: how to think when designing. It gathers the principles from Eric Evans' book that don't fit into a single pattern, yet shape the quality of the entire architecture.
Knowledge belongs in code, not in SQL
When a team starts building a system, business rules often settle wherever it's convenient right now: in SQL queries, in controllers, in scripts. A year later, nobody remembers where the discount for a "gold" customer comes from — is it a WHERE in a query or a condition in a service?
Evans calls this process Knowledge Crunching. The idea: the model should grow out of a dialogue with the people who understand the domain. And that knowledge should stay in the domain code rather than leaking out.
Here is what a knowledge leak looks like:
// Knowledge hidden in SQL — who will read this a year from now?
type OrderDao struct {
pool *pgxpool.Pool
}
func (d *OrderDao) RiskFor(ctx context.Context, id OrderID) (RiskRating, error) {
// SELECT CASE WHEN total > 10000 THEN 'HIGH' WHEN ... END
return RiskLow, nil
}
// A copy of the DB structure instead of a model — no behavior at all
type OrderRecord struct {
ID int64
TotalAmount decimal.Decimal
StatusCode string
}
Here is what an explicit domain concept looks like:
type CustomerTierResolver struct{}
func (r CustomerTierResolver) Resolve(customer Customer) CustomerTier {
switch {
case customer.OrdersInLastYear() >= 20:
return TierGold
case customer.OrdersInLastYear() >= 5:
return TierSilver
default:
return TierBronze
}
}
The rule reads like a rule — no SQL, no magic numbers buried in comments.
The main trap: freezing the model too early or copying the database structure as the domain model. Early models are always naive — and that's fine, they need to be actively revised.
The model belongs in code, not just on the wiki
Picture this: an architect draws a beautiful diagram on the whiteboard. The developers take a look and then write the code their own way. Model and implementation drift apart, and six months later the diagram describes an imaginary system, not the real one.
Evans calls this Model-Driven Design: the model must be expressed directly in the code. If the model exists only in an analyst's head or on the wiki — that's not Model-Driven Design.
The most common problem is the anemic model: an object with only fields and getters, with all the logic in a separate service. It looks tidy, but the business rules start leaking into services, controllers, mappings — and over time it's unclear where to look for the truth.
// Anemic model: an entity without behavior
type Order struct {
ID int64
Status OrderStatus
// only exported fields, no rules
}
// All the logic sits in a service
type OrderService struct{}
func (s *OrderService) Confirm(order *Order) error {
if order.Status != OrderStatusDraft {
return errors.New("cannot confirm")
}
order.Status = OrderStatusConfirmed
return nil
}
In Model-Driven Design, behavior lives where the data lives:
var ErrOrderCannotBeConfirmed = errors.New("order cannot be confirmed")
type Order struct {
id OrderID
status OrderStatus
events []DomainEvent
}
func (o *Order) Confirm() error {
if !o.canBeConfirmed() {
return ErrOrderCannotBeConfirmed
}
o.status = OrderStatusConfirmed
return nil
}
func (o *Order) MarkAsPaid(paymentID PaymentID) {
o.status = OrderStatusPaid
o.events = append(o.events, NewOrderPaidEvent(o.id, paymentID))
}
Now the rule "you can't confirm an unsuitable order" lives inside Order and doesn't leak anywhere.
The domain must not depend on infrastructure
A classic problem: a developer writes a domain struct and immediately slaps db tags onto it and ties its methods to a connection pool. Then it turns out that this struct can't be tested without a database, can't be reused without the driver, and the schema can't be changed without rewriting the domain.
The solution is a layered architecture with clear rules: UI → Application → Domain → Infrastructure. The domain never imports anything from outside itself.
// The Application Service coordinates the scenario, the domain makes the decisions
type OrderAppService struct {
repo OrderRepository
}
func (s *OrderAppService) ConfirmOrder(ctx context.Context, id OrderID) error {
order, err := s.repo.ByID(ctx, id)
if err != nil {
return err
}
if err := order.Confirm(); err != nil { // the decision is inside the domain
return err
}
return s.repo.Save(ctx, order)
}
// The repository interface is declared in the domain
type OrderRepository interface {
ByID(ctx context.Context, id OrderID) (*Order, error)
Save(ctx context.Context, order *Order) error
}
// The implementation lives in the infrastructure layer, the domain knows nothing about it
type PgxOrderRepository struct {
pool *pgxpool.Pool
}
func (r *PgxOrderRepository) ByID(ctx context.Context, id domain.OrderID) (*domain.Order, error) { /* pgx */ }
func (r *PgxOrderRepository) Save(ctx context.Context, order *domain.Order) error { /* pgx */ }
Anti-patterns:
// A domain struct that knows about the DB schema
type Order struct {
ID int64 `db:"id"` // storage tags right inside the domain
Status string `db:"status_code"`
}
// The Application Service works directly with infrastructure
type OrderAppService struct {
pool *pgxpool.Pool // infrastructure inside the application layer
}
Hidden concepts are worth making visible
When a long, nameless condition shows up in the code, it's a sign that the domain contains an important concept that no one has named.
// Inexpressive logic — what does it mean?
if amount.LessThanOrEqual(limit) && !customer.IsBlocked() && customer.Age() >= 18 {
// allow the operation
}
Candidates for extraction are policies, roles, time periods, domain events:
// A policy — a named rule
type CreditApprovalPolicy struct{}
func (p CreditApprovalPolicy) Allows(customer Customer, amount Money) bool {
return !customer.IsBlocked() &&
customer.IsAdult() &&
customer.CreditLimit().Remaining().GreaterThan(amount)
}
// A period — a domain concept instead of two dates
type BillingPeriod struct {
from time.Time
to time.Time
}
func (p BillingPeriod) Includes(date time.Time) bool {
return !date.Before(p.from) && !date.After(p.to)
}
// An event — an explicit domain fact
type OrderExpired struct {
OrderID OrderID
ExpiredAt time.Time
}
Explicit types make testing and reuse easier. A named rule can be discussed with an expert, changed, and tested on its own.
Sometimes an explicit concept turns the model upside down — Evans calls this a breakthrough. Before the breakthrough: a discount is just a hasDiscount bool flag. After the breakthrough: a discount is a Discount object with rules for how it applies. The code becomes simpler and more expressive.
The API should tell you what is happening
A bad sign is a method whose name says nothing:
order.UpdateStatus(2) // what is 2?
order.Process(true, false) // what do these booleans do?
A good public API tells you what is happening, not how it's done inside:
order.Confirm()
order.CancelByCustomer(reason)
order.MarkAsPaid(paymentID)
Evans calls this principle Intention-Revealing Interfaces. Reading the method call, you grasp the meaning of the operation without peeking into the implementation.
Computations must not change state
Another principle from the Supple Design section is Side-Effect-Free Functions. If a method computes something, it must not change something at the same time. This makes the code predictable and easy to test.
// Computation + side effect — a dangerous mix
type PricingService struct{}
func (s *PricingService) CalculateAndApplyDiscount(order *Order) Money {
discount := s.computeDiscount(order)
order.SetTotal(order.Total().Subtract(discount)) // mutates the order!
return discount
}
It's better to separate them:
// A pure computation — changes nothing
type TaxCalculator struct{}
func (c TaxCalculator) TaxFor(order Order) Money {
return order.Subtotal().Multiply(taxRate)
}
// A command — changes state, returns nothing
func (o *Order) ApplyDiscount(discount Discount) {
o.total = discount.ApplyTo(o.total)
o.events = append(o.events, NewDiscountAppliedEvent(o.id, discount))
}
Invariants are checked where state changes
An invariant is a rule that must always hold. If an object can end up in an invalid state, sooner or later it will cause a bug that's hard to catch.
// Silently allows a negative balance
func (a *Account) Withdraw(amount Money) {
a.balance = a.balance.Subtract(amount) // what if amount > balance?
}
Invariants are checked where the change happens:
var (
ErrNegativeAmount = errors.New("amount must be positive")
ErrInsufficientFunds = errors.New("insufficient funds")
)
func (a *Account) Withdraw(amount Money) error {
if amount.IsNegative() {
return ErrNegativeAmount
}
if a.balance.LessThan(amount) {
return ErrInsufficientFunds
}
a.balance = a.balance.Subtract(amount)
return nil
}
Evans calls this principle Assertions — assertions about state. Errors are caught close to their source instead of surfacing in an unexpected place.
GoF patterns help express the domain
Strategy, Factory, Specification — technical patterns are justified when they emphasize the meaning of the domain, rather than merely adding layers of abstraction.
// Strategy — for varying behavior
type ShippingPolicy interface {
CostFor(shipment Shipment) Money
}
type ExpressShipping struct{}
func (s ExpressShipping) CostFor(shipment Shipment) Money { /* ... */ }
// Factory — for creation with domain rules
func ShippingPolicyForOrder(order Order) ShippingPolicy {
if order.IsExpress() {
return ExpressShipping{}
}
return StandardShipping{}
}
// Specification — for reusable rules
type CanConfirmOrder struct{}
func (s CanConfirmOrder) IsSatisfiedBy(order Order) bool {
return order.IsPaid() && order.HasItems()
}
The anti-pattern is a pattern for the pattern's sake, with no domain meaning:
// The ConfigManager singleton has nothing to do with the domain
type ConfigManager struct{}
var configManagerInstance = &ConfigManager{}
Refactoring in DDD is not a technical exercise but a tool for deepening the model. Every change should bring the code closer to the language of the domain.
In short
- Knowledge in code — business rules must not hide in SQL queries or in helper objects with no behavior.
- Model in code — the anemic model (an object with fields + a service holding all the logic) is an anti-pattern; behavior lives where the data lives.
- Domain isolation — the domain does not depend on infrastructure; dependencies point inward: UI → Application → Domain ← Infrastructure.
- Explicit concepts — policies, roles, periods and events deserve types of their own, rather than living inside
if/else. - Intention-Revealing Interfaces —
Confirm()instead ofUpdateStatus(2); reading the call, you grasp the meaning. - Side-Effect-Free Functions — computations and commands are kept separate; a method either computes or changes state.
- Invariants — checked where the change happens, not later in some random place.
- GoF patterns — Strategy, Factory, Specification are justified when they emphasize the domain, not when they add layers for the sake of layers.
What to read next
- What DDD is and why you need it — the starting point if you haven't read it yet.
- DDD strategic patterns — Bounded Context, Context Map, Ubiquitous Language.
- DDD tactical patterns — Entity, Value Object, Aggregate, Repository.