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 .