background-shape
Go article cover illustration on a gradient background
June 10, 2022 · 5 min read · by Muhammad Amal programming
Advertisement

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.

Advertisement

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 .

Advertisement