Domain-Driven Design: Modelling the Subscription Context in Go

June 10, 2026 · 20 min read

The whiteboard has four boxes on it. The bounded contexts are drawn. Everyone agrees on the boundaries. And then Tom says the thing that nobody else will: “So how do we actually write it?”

Charlotte’s bounded context workshop gave the team four contexts: Subscription, Billing, Supply Matching, and Fulfilment. Later, Subscription and Billing merged into Commercial. The diagrams are clear. The event flows make sense. But diagrams don’t ship.

Tom and Kai sit down on a Monday morning with the Go codebase open. The existing code is a flat main package with everything in one directory. Models, handlers, database queries, Stripe calls, all neighbours.

“Where do we start?” Kai asks.

“Pick one context,” Charlotte says. “The one with the clearest boundary. Build it properly. Let the rest catch up.”

They pick Subscription. It’s the heart of the domain. A customer subscribes, pauses, cancels, changes box size. It doesn’t touch Stripe. It doesn’t pack boxes. It doesn’t match farms. It just tracks the commitment between a customer and their weekly delivery.

Value Objects: types that mean something

The first thing Charlotte asks is: “What’s a subscription ID?”

Tom shrugs. “A string. UUID probably.”

“And a customer ID?”

“Also a string.”

“So you can pass a customer ID where a subscription ID is expected and the compiler won’t say a word.”

Tom sees it immediately. In the existing codebase, three bugs in the last month came from swapping ID arguments. A function takes customerID, subscriptionID string and someone passes them in the wrong order. The tests don’t catch it because both are valid UUIDs. The bug shows up in production when a customer’s billing record points at someone else’s subscription.

In Go, the fix is a type definition:

// file: subscription/subscription.go
package subscription

type SubscriptionID string
type CustomerID string

Now a function signature tells you what it needs:

func (s *Service) Pause(id SubscriptionID, reason PauseReason) error

Pass a CustomerID where a SubscriptionID is expected and the compiler stops you. No test required. No runtime error. The type system caught it before the code ran.

Value objects go further than IDs. A box size isn’t a string; it’s one of a known set of values:

// file: subscription/subscription.go
type BoxSize int

const (
	BoxSizeSmall  BoxSize = iota
	BoxSizeMedium
	BoxSizeLarge
)

func (b BoxSize) String() string {
	switch b {
	case BoxSizeSmall:
		return "small"
	case BoxSizeMedium:
		return "medium"
	case BoxSizeLarge:
		return "large"
	default:
		return "unknown"
	}
}

func ParseBoxSize(s string) (BoxSize, error) {
	switch s {
	case "small":
		return BoxSizeSmall, nil
	case "medium":
		return BoxSizeMedium, nil
	case "large":
		return BoxSizeLarge, nil
	default:
		return 0, fmt.Errorf("unknown box size: %q", s)
	}
}

An email address isn’t a string either. It’s a value that has been validated:

// file: subscription/subscription.go
type EmailAddress struct {
	value string
}

func NewEmailAddress(raw string) (EmailAddress, error) {
	if !strings.Contains(raw, "@") {
		return EmailAddress{}, fmt.Errorf("invalid email: %q", raw)
	}
	return EmailAddress{value: strings.TrimSpace(raw)}, nil
}

func (e EmailAddress) String() string {
	return e.value
}

The struct field is unexported. You cannot create an EmailAddress without going through NewEmailAddress. Every EmailAddress in the system has been validated. The type is the proof.

Tom stares at this for a moment. “We had a bug last month where someone signed up with a space before their email. Sam spent an hour debugging why their confirmation didn’t arrive.”

“That bug is now impossible,” Kai says.

“Not impossible,” Charlotte corrects. “Impossible to create inside the domain. You still need to validate at the boundary where raw input enters the system. But once it’s an EmailAddress, everyone downstream can trust it.”

The entity: Subscription

An entity has identity. Two subscriptions with the same box size and delivery day — even for the same subscriber — are not the same subscription. The ID is what makes them distinct.

// file: subscription/subscription.go
type Status int

const (
	StatusPending Status = iota
	StatusActive
	StatusPaused
	StatusCancelled
)

type Subscription struct {
	id           SubscriptionID
	customerID   CustomerID
	boxSize      BoxSize
	status       Status
	currentPause *PauseDetails
	createdAt    time.Time
	updatedAt    time.Time
	trialEndsAt  time.Time
	events       []Event
}

Every field is unexported. You cannot reach into a Subscription and flip its status directly. The only way to change state is through methods that enforce the rules:

// file: subscription/subscription.go

// trialPeriod is Maya's rule: cancel inside the first week and it counts
// as a trial cancellation.
const trialPeriod = 7 * 24 * time.Hour

func NewSubscription(id SubscriptionID, customerID CustomerID, boxSize BoxSize) *Subscription {
	now := time.Now()
	s := &Subscription{
		id:          id,
		customerID:  customerID,
		boxSize:     boxSize,
		status:      StatusPending,
		createdAt:   now,
		updatedAt:   now,
		trialEndsAt: now.Add(trialPeriod),
	}
	s.record(SubscriptionCreated{
		SubscriptionID: id,
		CustomerID:     customerID,
		BoxSize:        boxSize,
		OccurredAt:     s.createdAt,
	})
	return s
}

The constructor records a domain event. We’ll come back to events in the next post.

Behaviour lives on the entity

Pausing a subscription isn’t “set status to paused.” It’s a business operation with rules:

// file: subscription/subscription.go
type PauseReason int

const (
	PauseReasonHoliday PauseReason = iota
	PauseReasonFinancial
	PauseReasonOther
)

// PauseDetails records why a subscription is currently paused. A nil
// pointer means "not paused"; the absence of a pause gets its own honest
// representation rather than a zero value standing in for it.
type PauseDetails struct {
	Reason PauseReason
	At     time.Time
}

func (s *Subscription) Pause(reason PauseReason) error {
	if s.status != StatusActive {
		return fmt.Errorf("cannot pause subscription in status %v", s.status)
	}
	s.status = StatusPaused
	s.updatedAt = time.Now()
	s.currentPause = &PauseDetails{Reason: reason, At: s.updatedAt}
	s.record(SubscriptionPaused{
		SubscriptionID: s.id,
		Reason:         reason,
		OccurredAt:     s.updatedAt,
	})
	return nil
}

You can only pause an active subscription. You must provide a reason. The method enforces both rules. If a developer (or an LLMA neural network trained to predict the next token in a sequence, large enough that it generalises to tasks it wasn’t explicitly trained for. ) tries to pause a cancelled subscription, they get an error. No defensive check needed in the caller. The domain object protects itself.

Resuming has its own rule: you can only resume from paused.

// file: subscription/subscription.go
func (s *Subscription) Resume() error {
	if s.status != StatusPaused {
		return fmt.Errorf("cannot resume subscription in status %v", s.status)
	}
	s.status = StatusActive
	s.currentPause = nil
	s.updatedAt = time.Now()
	s.record(SubscriptionResumed{
		SubscriptionID: s.id,
		OccurredAt:     s.updatedAt,
	})
	return nil
}

Resuming clears currentPause back to nil. That pointer is doing real modelling work: nil means “not paused”, and a *PauseDetails means “paused, and here’s why and since when.” If the reason were a plain PauseReason field on the subscription, “not paused” would have to borrow a zero value, and the zero value of PauseReason is PauseReasonHoliday, so an active subscription would read as paused-for-a-holiday. The nullable value object refuses to let those two states share a representation. And the history of past pauses isn’t lost: every Pause emits a SubscriptionPaused event carrying its reason and timestamp, so the event stream already answers “why has this subscriber paused before?” without the entity hoarding a list it never reasons over.

Cancelling is more nuanced. Maya has a policy: if a subscription has been active less than a week, it’s a trial cancellation and she wants to know about it. The domain captures this:

// file: subscription/subscription.go
func (s *Subscription) Cancel() error {
	if s.status == StatusCancelled {
		return fmt.Errorf("subscription already cancelled")
	}
	s.status = StatusCancelled
	s.updatedAt = time.Now()

	evt := SubscriptionCancelled{
		SubscriptionID: s.id,
		OccurredAt:     s.updatedAt,
		WasTrialPeriod: s.updatedAt.Before(s.trialEndsAt),
	}
	s.record(evt)
	return nil
}

The WasTrialPeriod field isn’t stored on the subscription; it’s derived from trialEndsAt and recorded in the event. Stamping trialEndsAt at creation matters: the trial window is part of the agreement struck when the subscriber signed up, so it’s a fact the subscription carries, not a calculation re-run at cancel time. If Maya later changes “a week” to “ten days”, trialPeriod moves and new subscriptions get the new window, while subscriptions already created keep the terms they were offered. The subscription doesn’t know what happens downstream with that classification. It just describes what happened.

Box size changes also have a rule: you can’t change the size of a paused or cancelled subscription.

// file: subscription/subscription.go
func (s *Subscription) ChangeBoxSize(newSize BoxSize) error {
	if s.status != StatusActive {
		return fmt.Errorf("cannot change box size in status %v", s.status)
	}
	if s.boxSize == newSize {
		return nil // no-op, no event
	}
	old := s.boxSize
	s.boxSize = newSize
	s.updatedAt = time.Now()
	s.record(BoxSizeChanged{
		SubscriptionID: s.id,
		OldSize:        old,
		NewSize:        newSize,
		OccurredAt:     s.updatedAt,
	})
	return nil
}

Notice the no-op case. Changing from medium to medium isn’t an error. It’s just nothing. No event recorded, no side effects triggered. The domain handles idempotency naturally.

Read access: getters that reveal intent

The fields are unexported, so the entity needs accessors. But these aren’t mechanical getters; they reveal domain intent:

// file: subscription/subscription.go
func (s *Subscription) ID() SubscriptionID    { return s.id }
func (s *Subscription) CustomerID() CustomerID { return s.customerID }
func (s *Subscription) BoxSize() BoxSize       { return s.boxSize }
func (s *Subscription) IsActive() bool         { return s.status == StatusActive }
func (s *Subscription) IsPaused() bool         { return s.status == StatusPaused }
func (s *Subscription) IsCancelled() bool      { return s.status == StatusCancelled }

No Status() Status getter. The callers don’t need to know the internal representation. They need to know “is this subscription active?” The method answers the question the caller is actually asking.

The repository: persistence without details

The Subscription context needs to store and retrieve subscriptions. But the domain doesn’t know about databases. It defines an interface:

// file: subscription/repository.go
type Repository interface {
	Save(ctx context.Context, sub *Subscription) error
	FindByID(ctx context.Context, id SubscriptionID) (*Subscription, error)
	FindByCustomerID(ctx context.Context, id CustomerID) ([]*Subscription, error)
}

That’s it. Three methods. The domain says what it needs. The infrastructure provides it. A PostgreSQL implementation, an in-memory implementation for tests, a future DynamoDB implementation: the domain doesn’t care.

The in-memory implementation for tests is trivial:

// file: subscription/repository_inmemory.go
type InMemoryRepository struct {
	mu   sync.RWMutex
	subs map[SubscriptionID]*Subscription
}

func NewInMemoryRepository() *InMemoryRepository {
	return &InMemoryRepository{
		subs: make(map[SubscriptionID]*Subscription),
	}
}

func (r *InMemoryRepository) Save(_ context.Context, sub *Subscription) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.subs[sub.ID()] = sub
	return nil
}

func (r *InMemoryRepository) FindByID(_ context.Context, id SubscriptionID) (*Subscription, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	sub, ok := r.subs[id]
	if !ok {
		return nil, fmt.Errorf("subscription not found: %s", id)
	}
	return sub, nil
}

func (r *InMemoryRepository) FindByCustomerID(_ context.Context, customerID CustomerID) ([]*Subscription, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	var result []*Subscription
	for _, sub := range r.subs {
		if sub.CustomerID() == customerID {
			result = append(result, sub)
		}
	}
	return result, nil
}

Tom writes the PostgreSQL implementation later. The domain tests don’t wait for it.

Package layout

Charlotte suggests a directory structure that mirrors the bounded contexts:

greenbox/
├── subscription/
│   ├── subscription.go      // entity, value objects
│   ├── events.go            // domain events
│   ├── repository.go        // repository interface
│   └── service.go           // application service
├── billing/
│   ├── ...
├── supplymatching/
│   ├── ...
├── fulfilment/
│   ├── ...
└── infrastructure/
    ├── postgres/
    │   ├── subscription_repo.go
    │   └── billing_repo.go
    └── eventbus/
        └── inmemory.go

Each bounded context is a Go package. The package boundary is the context boundary. Go’s package-level visibility enforces the rule: code in billing can’t access unexported fields in subscription. The language does the policing.

“I’ve seen teams draw boundaries and then ignore them,” Charlotte says. “Go won’t let you. If it’s unexported, it’s unexported. The compiler is the boundary guard.”

Tom, who has spent two years fighting the urge to put everything in package main, admits this is the first time language design has made an architecture decision for him.

Testing the domain

The domain tests are pure business logic. No database. No HTTP. No test containers. Just the entity and its rules:

// file: subscription/subscription_test.go
func TestNewSubscription(t *testing.T) {
	sub := subscription.NewSubscription("sub-1", "cust-1", subscription.BoxSizeMedium)

	if !sub.IsActive() && sub.ID() != "" {
		// new subscriptions start pending, not active
	}
	if sub.BoxSize() != subscription.BoxSizeMedium {
		t.Errorf("expected medium, got %v", sub.BoxSize())
	}
}

func TestPauseRequiresActive(t *testing.T) {
	sub := subscription.NewSubscription("sub-1", "cust-1", subscription.BoxSizeMedium)
	// sub is pending, not active -- pause should fail
	err := sub.Pause(subscription.PauseReasonHoliday)
	if err == nil {
		t.Error("expected error pausing non-active subscription")
	}
}

func TestPauseAndResume(t *testing.T) {
	sub := subscription.NewSubscription("sub-1", "cust-1", subscription.BoxSizeMedium)
	sub.Activate() // move to active first
	if err := sub.Pause(subscription.PauseReasonHoliday); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !sub.IsPaused() {
		t.Error("expected paused")
	}
	if err := sub.Resume(); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !sub.IsActive() {
		t.Error("expected active after resume")
	}
}

func TestCannotPauseTwice(t *testing.T) {
	sub := subscription.NewSubscription("sub-1", "cust-1", subscription.BoxSizeMedium)
	sub.Activate()
	_ = sub.Pause(subscription.PauseReasonHoliday)
	err := sub.Pause(subscription.PauseReasonHoliday)
	if err == nil {
		t.Error("expected error pausing already-paused subscription")
	}
}

func TestCancelledSubscriptionCannotChangeBoxSize(t *testing.T) {
	sub := subscription.NewSubscription("sub-1", "cust-1", subscription.BoxSizeMedium)
	sub.Activate()
	_ = sub.Cancel()
	err := sub.ChangeBoxSize(subscription.BoxSizeLarge)
	if err == nil {
		t.Error("expected error changing box size on cancelled subscription")
	}
}

These tests run in milliseconds. They describe the business rules in code. When Maya asks “can a customer change their box size after they’ve cancelled?” the answer is in the test: no.

What Tom learned

Tom starts the week where he’s been for months: grudgingly going along with value objects because Priya insisted and the CLAUDE.md says so, but privately thinking it’s a lot of ceremony for something that used to be a struct with public fields.

By Wednesday, he’s found two bugs in the existing codebase that the new types would have prevented. One of them has been there since before the CLAUDE.md existed. By Friday, he’s refactored the Subscription type three times, not because Charlotte told him to, but because each refactor made the tests clearer and the rules more explicit.

“The weird thing,” he tells Kai over coffee, “is that I’ve been writing value objects for weeks because Priya put them in the conventions. But I didn’t get it until I built a whole aggregate out of them. I’m writing more code, but every piece does one thing and I can explain why it’s there.”

Kai nods. “And when I PromptThe input you hand to an LLM – system instructions, user message, examples, retrieved documents, tool descriptions, the lot. the LLM with the package as context, the generated code stays inside the boundary. It’s not reaching into billing or fulfilment because those packages don’t exist in the prompt.”

This is the point Charlotte has been making since the boundary workshop. The structure isn’t just for humans. It’s for every tool that reads the code, including the LLM that generates the next feature.

What comes next

The Subscription context works in isolation. But a subscription that never tells Billing it was created is useless. Next: domain events and how bounded contexts communicate in Go.

The next chapter, Domain-Driven Design: Events Across Boundaries in Go, publishes around 11 Jun.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.