Domain-Driven Entities in Go
TL;DR — Entity = type with identity that enforces its own invariants. Value object = type without identity, immutable. Use constructors to gate creation. No setters; methods that enforce business rules. Errors live in the domain package. Nothing in domain imports framework code.
After Go DI, the inside of the structs we’re wiring up. Domain modeling is where Clean Architecture earns its keep — business rules live in domain types, not scattered across services and handlers.
Entity vs value object
Two patterns to know.
Entity — type with identity (usually a UUID or DB ID). Two entities can have the same field values and still be distinct. Example: Subscription.
Value object — type without identity. Two value objects with the same fields are equal. Immutable. Example: Money, EmailAddress, BillingPeriod.
Practically:
// Entity
type Subscription struct {
id uuid.UUID // unexported — identity
customerID uuid.UUID
plan Plan // value object
status Status
period BillingPeriod // value object
}
// Value object
type Money struct {
amount int64 // cents
currency string
}
func NewMoney(cents int64, currency string) (Money, error) {
if currency == "" || len(currency) != 3 {
return Money{}, ErrInvalidCurrency
}
return Money{amount: cents, currency: currency}, nil
}
func (m Money) Cents() int64 { return m.amount }
func (m Money) Currency() string { return m.currency }
Value objects are constructed via a function that validates. They have getters but no setters — immutable.
Unexported fields, constructor, accessor
The Go idiom: keep fields unexported, expose accessors:
type Subscription struct {
id uuid.UUID
customerID uuid.UUID
plan Plan
status Status
}
func NewSubscription(customerID uuid.UUID, plan Plan, now time.Time) (*Subscription, error) {
if plan.ID == "" {
return nil, ErrInvalidPlan
}
return &Subscription{
id: uuid.New(),
customerID: customerID,
plan: plan,
status: StatusActive,
}, nil
}
func (s *Subscription) ID() uuid.UUID { return s.id }
func (s *Subscription) CustomerID() uuid.UUID { return s.customerID }
func (s *Subscription) Plan() Plan { return s.plan }
func (s *Subscription) Status() Status { return s.status }
The constructor is the only way to make a valid Subscription. Callers can’t construct an invalid one (no &Subscription{} outside the package).
Adapter layer needs a way to reconstruct entities from DB rows. Two patterns:
Pattern A — second constructor for reconstruction:
// For DB rehydration. Accepts everything.
func RestoreSubscription(id, customerID uuid.UUID, plan Plan, status Status) *Subscription {
return &Subscription{id: id, customerID: customerID, plan: plan, status: status}
}
Restore skips validation because the data is presumed valid (it came from the database). Adapter uses this.
Pattern B — accept exported “raw” struct then convert:
Less common in Go; common in Java/PHP. Skip in Go.
Methods enforce invariants
The reason for unexported fields: state changes go through methods that enforce rules.
func (s *Subscription) Cancel(now time.Time) error {
if s.status == StatusCanceled {
return ErrAlreadyCanceled
}
s.status = StatusCanceled
s.canceledAt = now
return nil
}
func (s *Subscription) Renew(now time.Time, period BillingPeriod) error {
if s.status != StatusActive {
return ErrCannotRenewInactive
}
s.period = period
s.renewsAt = now.Add(period.Duration())
return nil
}
You can’t bypass these by setting fields directly from outside the package. The state machine is enforced by the type.
Domain errors
Define errors as package-level values:
package domain
import "errors"
var (
ErrInvalidPlan = errors.New("invalid plan")
ErrAlreadyCanceled = errors.New("subscription already canceled")
ErrCannotRenewInactive = errors.New("cannot renew inactive subscription")
ErrInvalidCurrency = errors.New("invalid currency")
)
Callers check with errors.Is:
err := sub.Cancel(time.Now())
if errors.Is(err, domain.ErrAlreadyCanceled) {
// handle gracefully — return 409 from HTTP, etc.
}
For errors with structured data (e.g., the invalid value), use typed errors:
type ErrInvalidStatus struct {
Got Status
}
func (e ErrInvalidStatus) Error() string {
return fmt.Sprintf("invalid status: %s", e.Got)
}
Caller does var e domain.ErrInvalidStatus; if errors.As(err, &e) { ... }. Useful when handlers need to surface specific data.
What goes in domain — checklist
Yes:
- Entity types and their methods
- Value objects
- Domain services (pure functions over entities)
- Domain errors
- Domain events (struct definitions only — publishing is adapter concern)
- Pure business logic (proration, tax calculation rules, eligibility checks)
No:
- HTTP types (
*http.Request) - DB types (
pgx.Conn) - Framework imports
- JSON tags (they’re for adapters)
- Logging calls (domain doesn’t know what logger to use)
- Time.Now() called directly (pass
now time.Timeorclock func() time.Time)
A complete domain file
package domain
import (
"errors"
"time"
"github.com/google/uuid"
)
type Status string
const (
StatusActive Status = "active"
StatusPastDue Status = "past_due"
StatusCanceled Status = "canceled"
)
type Plan struct {
ID string
PriceCents int64
Currency string
Interval time.Duration
}
func NewPlan(id string, priceCents int64, currency string, interval time.Duration) (Plan, error) {
if id == "" {
return Plan{}, ErrInvalidPlan
}
if priceCents < 0 {
return Plan{}, ErrNegativePrice
}
return Plan{ID: id, PriceCents: priceCents, Currency: currency, Interval: interval}, nil
}
type Subscription struct {
id, customerID uuid.UUID
plan Plan
status Status
startedAt time.Time
renewsAt time.Time
canceledAt *time.Time
}
func NewSubscription(customerID uuid.UUID, plan Plan, now time.Time) (*Subscription, error) {
if plan.ID == "" {
return nil, ErrInvalidPlan
}
return &Subscription{
id: uuid.New(),
customerID: customerID,
plan: plan,
status: StatusActive,
startedAt: now,
renewsAt: now.Add(plan.Interval),
}, nil
}
func RestoreSubscription(id, customerID uuid.UUID, plan Plan, status Status, startedAt, renewsAt time.Time, canceledAt *time.Time) *Subscription {
return &Subscription{id, customerID, plan, status, startedAt, renewsAt, canceledAt}
}
func (s *Subscription) ID() uuid.UUID { return s.id }
func (s *Subscription) Status() Status { return s.status }
func (s *Subscription) Plan() Plan { return s.plan }
func (s *Subscription) Cancel(now time.Time) error {
if s.status == StatusCanceled {
return ErrAlreadyCanceled
}
s.status = StatusCanceled
s.canceledAt = &now
return nil
}
var (
ErrInvalidPlan = errors.New("invalid plan")
ErrNegativePrice = errors.New("negative price")
ErrAlreadyCanceled = errors.New("already canceled")
)
That’s a complete, testable, framework-free domain file. ~50 lines. Tests run in 0.005 seconds.
Common Pitfalls
Exporting fields for “convenience.” Now callers can set invalid states. Always private; expose methods.
Anemic entities — structs with no methods. That’s just a DTO. Add the rules.
Domain that imports an ORM. db:"customer_id" struct tags = ORM concern. Keep them out.
time.Now() inside the domain. Untestable. Inject as function or clock.
Domain that imports HTTP. Same — domain doesn’t care if it’s served over HTTP.
Returning database row structs from domain methods. Wrong shape leaks. Define your own types.
Wrapping Up
Entities with identity + value objects + invariant-enforcing methods + domain errors. 50-line files; fast tests; framework-independent. Monday: repositories — the port interfaces use cases consume.