background-shape
Domain-Driven Entities in Go
June 10, 2022 · 5 min read · by Muhammad Amal programming

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.Time or clock 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.