Domain-Driven Design: Events Across Boundaries in Go

June 11, 2026 · 18 min read

A subscription that never tells anyone it was created is a secret. Secrets don’t ship boxes.

The Subscription context is clean. Value objects, entities, business rules enforced by the type system. But it lives in a vacuum. When a customer subscribes, Billing needs to create an invoice. Supply Matching needs to add demand. Fulfilment needs a delivery slot. None of these should be the Subscription context’s problem.

This is where domain events earn their keep.

What is a domain event?

A domain event is a record of something that happened. Past tense. SubscriptionCreated, not CreateSubscription. It’s not a command, it doesn’t ask anyone to do anything. It’s a fact. The Subscription context says “this happened” and walks away. Other contexts decide whether they care.

The context map Charlotte drew shows the event flows: Subscription publishes SubscriptionCreated, SubscriptionPaused, SubscriptionCancelled. Billing listens. Supply Matching listens. Neither knows about the other.

Events as Go structs

In the previous post, the Subscription entity recorded events. Here’s what those events look like:

// file: subscription/events.go
package subscription

import "time"

type Event interface {
	EventName() string
	OccurredAt() time.Time
}

type SubscriptionCreated struct {
	SubscriptionID SubscriptionID
	CustomerID     CustomerID
	BoxSize        BoxSize
	Timestamp      time.Time
}

func (e SubscriptionCreated) EventName() string    { return "subscription.created" }
func (e SubscriptionCreated) OccurredAt() time.Time { return e.Timestamp }

type SubscriptionPaused struct {
	SubscriptionID SubscriptionID
	Reason         PauseReason
	Timestamp      time.Time
}

func (e SubscriptionPaused) EventName() string    { return "subscription.paused" }
func (e SubscriptionPaused) OccurredAt() time.Time { return e.Timestamp }

type SubscriptionCancelled struct {
	SubscriptionID SubscriptionID
	WasTrialPeriod bool
	Timestamp      time.Time
}

func (e SubscriptionCancelled) EventName() string    { return "subscription.cancelled" }
func (e SubscriptionCancelled) OccurredAt() time.Time { return e.Timestamp }

type SubscriptionResumed struct {
	SubscriptionID SubscriptionID
	Timestamp      time.Time
}

func (e SubscriptionResumed) EventName() string    { return "subscription.resumed" }
func (e SubscriptionResumed) OccurredAt() time.Time { return e.Timestamp }

type BoxSizeChanged struct {
	SubscriptionID SubscriptionID
	OldSize        BoxSize
	NewSize        BoxSize
	Timestamp      time.Time
}

func (e BoxSizeChanged) EventName() string    { return "subscription.box_size_changed" }
func (e BoxSizeChanged) OccurredAt() time.Time { return e.Timestamp }

Each event is a plain struct. No behaviour. No dependencies. Just data and a name. The Event interface is minimal, a name and a timestamp. That’s all consumers need to decide whether to handle it.

Collecting events on the aggregate

The Subscription entity collects events as state changes happen. It doesn’t publish them, it just remembers them:

// file: subscription/subscription.go
type Subscription struct {
	// ... fields from previous post
	events []Event
}

func (s *Subscription) record(e Event) {
	s.events = append(s.events, e)
}

func (s *Subscription) Events() []Event {
	return s.events
}

func (s *Subscription) ClearEvents() {
	s.events = nil
}

Why collect instead of publish immediately? Because the aggregate might fail. If Pause() is called but the repository can’t save the change, the event should never be published. The application service, the thing that orchestrates the use case, decides when events are safe to publish.

The event bus

The event bus is infrastructure. The domain defines what it needs:

// file: subscription/events.go
package subscription

import "context"

type EventPublisher interface {
	Publish(ctx context.Context, events ...Event) error
}

One method. The domain doesn’t know if events go to an in-memory channel, a message queue, or a database outbox table. It publishes; infrastructure delivers.

A simple in-memory implementation for getting started:

// file: infrastructure/eventbus/inmemory.go
package eventbus

import (
	"context"
	"sync"
)

type Handler func(ctx context.Context, event interface{}) error

type InMemoryBus struct {
	mu       sync.RWMutex
	handlers map[string][]Handler
}

func New() *InMemoryBus {
	return &InMemoryBus{
		handlers: make(map[string][]Handler),
	}
}

type namedEvent interface {
	EventName() string
}

func (b *InMemoryBus) Subscribe(eventName string, handler Handler) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.handlers[eventName] = append(b.handlers[eventName], handler)
}

func (b *InMemoryBus) Publish(ctx context.Context, events ...interface{}) error {
	b.mu.RLock()
	defer b.mu.RUnlock()
	for _, event := range events {
		named, ok := event.(namedEvent)
		if !ok {
			continue
		}
		for _, handler := range b.handlers[named.EventName()] {
			if err := handler(ctx, event); err != nil {
				return err
			}
		}
	}
	return nil
}

This is deliberately simple. Charlotte tells the team: “Start with in-memory. Move to a message queue when you need durability. The domain code won’t change.”

Tom asks the question she was waiting for: “What happens if a handler fails?”

“Right now, the publish fails and the whole operation rolls back. That’s fine for a startup with one process. When you have multiple services, you’ll need an outbox pattern, write the events to a database table in the same transaction as the aggregate, then a background worker publishes them. But that’s infrastructure. The domain stays the same.”

The application service: orchestrating the use case

The application service is the thin layer between HTTP handlers and the domain. It loads the aggregate, calls a method, saves, and publishes events:

// file: subscription/service.go
package subscription

import "context"

type Service struct {
	repo      Repository
	publisher EventPublisher
}

func NewService(repo Repository, publisher EventPublisher) *Service {
	return &Service{repo: repo, publisher: publisher}
}

func (svc *Service) CreateSubscription(
	ctx context.Context,
	id SubscriptionID,
	customerID CustomerID,
	boxSize BoxSize,
) error {
	sub := NewSubscription(id, customerID, boxSize)
	if err := svc.repo.Save(ctx, sub); err != nil {
		return fmt.Errorf("saving subscription: %w", err)
	}
	if err := svc.publisher.Publish(ctx, toPublishable(sub.Events())...); err != nil {
		return fmt.Errorf("publishing events: %w", err)
	}
	sub.ClearEvents()
	return nil
}

func (svc *Service) PauseSubscription(
	ctx context.Context,
	id SubscriptionID,
	reason PauseReason,
) error {
	sub, err := svc.repo.FindByID(ctx, id)
	if err != nil {
		return fmt.Errorf("finding subscription: %w", err)
	}
	if err := sub.Pause(reason); err != nil {
		return err
	}
	if err := svc.repo.Save(ctx, sub); err != nil {
		return fmt.Errorf("saving subscription: %w", err)
	}
	if err := svc.publisher.Publish(ctx, toPublishable(sub.Events())...); err != nil {
		return fmt.Errorf("publishing events: %w", err)
	}
	sub.ClearEvents()
	return nil
}

func toPublishable(events []Event) []interface{} {
	result := make([]interface{}, len(events))
	for i, e := range events {
		result[i] = e
	}
	return result
}

The pattern is the same every time: load, act, save, publish, clear. The service doesn’t contain business logic. It doesn’t decide whether a paused subscription can be paused again, that’s the entity’s job. The service coordinates.

Listening from another context

Billing doesn’t import the subscription package. It defines its own handler that reacts to events:

// file: billing/billing.go
package billing

import (
	"context"
	"fmt"
	"time"
)

type InvoiceID string
type SubscriptionRef string

type Invoice struct {
	id              InvoiceID
	subscriptionRef SubscriptionRef
	amount          int // cents
	status          string
	createdAt       time.Time
}

type InvoiceRepository interface {
	Save(ctx context.Context, inv *Invoice) error
	FindBySubscriptionRef(ctx context.Context, ref SubscriptionRef) (*Invoice, error)
}

Notice SubscriptionRef, not SubscriptionID. The Billing context doesn’t use the Subscription context’s types. It has its own reference to a subscription, a string that it received through an event. This is the anti-corruption layer in its simplest form: different types for different contexts, even when they refer to the same real-world thing.

The event handler:

// file: billing/handlers.go
package billing

import (
	"context"
	"fmt"
)

type SubscriptionCreatedEvent struct {
	SubscriptionID string
	CustomerID     string
	BoxSize        string
}

type EventHandlers struct {
	invoices InvoiceRepository
	pricing  PricingTable
}

func NewEventHandlers(invoices InvoiceRepository, pricing PricingTable) *EventHandlers {
	return &EventHandlers{invoices: invoices, pricing: pricing}
}

func (h *EventHandlers) OnSubscriptionCreated(ctx context.Context, event interface{}) error {
	e, ok := event.(SubscriptionCreatedEvent)
	if !ok {
		return fmt.Errorf("unexpected event type: %T", event)
	}
	amount := h.pricing.PriceFor(e.BoxSize)
	invoice := &Invoice{
		id:              InvoiceID(fmt.Sprintf("inv-%s", e.SubscriptionID)),
		subscriptionRef: SubscriptionRef(e.SubscriptionID),
		amount:          amount,
		status:          "pending",
		createdAt:       time.Now(),
	}
	return h.invoices.Save(ctx, invoice)
}

The handler receives a SubscriptionCreatedEvent, but this is Billing’s own struct, not the Subscription context’s. In a real system with a message queue, the event would arrive as JSON. The Billing context deserialises it into its own type. The Subscription context’s Go types never cross the boundary.

“Wait,” Tom says. “We have two structs that look almost identical. subscription.SubscriptionCreated and billing.SubscriptionCreatedEvent. Isn’t that duplication?”

Charlotte shakes her head. “It looks like duplication. It’s actually independence. When the Subscription context adds a field, say, PreferredDeliveryDay. Billing doesn’t break. It doesn’t know about that field. It doesn’t need to. The two structs evolve independently because the two contexts have different reasons to change.”

Wiring it together

At the application’s entry point – main.go or wherever the dependency injection happens, the contexts are wired together through the event bus:

// file: main.go
package main

import (
	"greenbox/billing"
	"greenbox/eventbus"
	"greenbox/subscription"
)

func main() {
	bus := eventbus.New()

	// Subscription context
	subRepo := subscription.NewInMemoryRepository()
	subService := subscription.NewService(subRepo, bus)

	// Billing context
	invRepo := billing.NewInMemoryInvoiceRepository()
	pricing := billing.NewPricingTable()
	billingHandlers := billing.NewEventHandlers(invRepo, pricing)

	// Connect: subscription events → billing handlers
	bus.Subscribe("subscription.created", billingHandlers.OnSubscriptionCreated)
	bus.Subscribe("subscription.cancelled", billingHandlers.OnSubscriptionCancelled)
	bus.Subscribe("subscription.paused", billingHandlers.OnSubscriptionPaused)

	// ... HTTP server setup, etc.
}

The subscription package doesn’t import billing. The billing package doesn’t import subscription. They connect through the event bus, which knows about neither. The dependency arrows point inward, both contexts depend on their own domain model, and the infrastructure (event bus, repositories) depends on the domain interfaces.

The adapter: translating between worlds

In practice, the event bus passes subscription.SubscriptionCreated but the Billing handler expects billing.SubscriptionCreatedEvent. An adapter translates:

// file: main.go
func adaptSubscriptionCreated(ctx context.Context, event interface{}) error {
	e, ok := event.(subscription.SubscriptionCreated)
	if !ok {
		return fmt.Errorf("unexpected event type: %T", event)
	}
	billingEvent := billing.SubscriptionCreatedEvent{
		SubscriptionID: string(e.SubscriptionID),
		CustomerID:     string(e.CustomerID),
		BoxSize:        e.BoxSize.String(),
	}
	return billingHandlers.OnSubscriptionCreated(ctx, billingEvent)
}

bus.Subscribe("subscription.created", adaptSubscriptionCreated)

This adapter lives in main.go, the composition root. It’s infrastructure glue, not domain logic. When the team moves to a message queue, the adapter is replaced by JSON serialisation on one side and deserialisation on the other. The domain code in both contexts stays untouched.

Tom studies the wiring. “So if we add a Supply Matching handler for the same event…”

bus.Subscribe("subscription.created", adaptForSupplyMatching)
bus.Subscribe("subscription.cancelled", adaptForSupplyMatching)

“Supply Matching gets notified without Subscription knowing it exists. And without Billing knowing either.”

“That’s the point,” Charlotte says. “Each context is a sovereign state. The event bus is the postal service. Nobody needs to know who else is getting mail.”

Testing event flows

Integration tests verify that the contexts communicate correctly:

// file: integration_test.go
func TestSubscriptionCreatedTriggersInvoice(t *testing.T) {
	bus := eventbus.New()
	subRepo := subscription.NewInMemoryRepository()
	subService := subscription.NewService(subRepo, bus)

	invRepo := billing.NewInMemoryInvoiceRepository()
	pricing := billing.NewFixedPricingTable(4500) // $45.00
	billingHandlers := billing.NewEventHandlers(invRepo, pricing)

	bus.Subscribe("subscription.created", func(ctx context.Context, event interface{}) error {
		e := event.(subscription.SubscriptionCreated)
		return billingHandlers.OnSubscriptionCreated(ctx, billing.SubscriptionCreatedEvent{
			SubscriptionID: string(e.SubscriptionID),
			CustomerID:     string(e.CustomerID),
			BoxSize:        e.BoxSize.String(),
		})
	})

	ctx := context.Background()
	err := subService.CreateSubscription(ctx, "sub-1", "cust-1", subscription.BoxSizeMedium)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	inv, err := invRepo.FindBySubscriptionRef(ctx, "sub-1")
	if err != nil {
		t.Fatalf("invoice not found: %v", err)
	}
	if inv.Amount() != 4500 {
		t.Errorf("expected 4500, got %d", inv.Amount())
	}
}

This test runs in-memory. No database, no message queue, no network. It proves that creating a subscription produces an event that Billing handles correctly. The test is fast, deterministic, and describes a real business scenario: “when a customer subscribes, they get an invoice.”

What the team noticed

After two weeks of building with events, Kai opens a PR for gift subscriptions. The PR adds a GiftSubscriptionCreated event. Billing subscribes to it and creates an invoice charged to the purchaser. Fulfilment subscribes and creates a delivery schedule for the recipient. Supply Matching subscribes and adjusts demand.

The PR touches three packages. Each change is self-contained. Tom reviews it in fifteen minutes.

“Remember the 47-file PR?” Charlotte asks.

Tom remembers. He remembers it every time a PR stays inside its boundaries. He’s not going to say it out loud, but the walls he resisted are the reason he can review code without anxiety now.

Priya notices something else. “The 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. is generating better code. When I give it the billing package as context and ask it to handle a new event, it creates a handler that matches the existing pattern. It doesn’t reach into subscription. It doesn’t import packages it shouldn’t. The structure is the PromptThe input you hand to an LLM – system instructions, user message, examples, retrieved documents, tool descriptions, the lot. .”

This is the same insight from the CLAUDE.md work: the style of your codebase is a few-shot prompt. Clean boundaries produce clean generations. Tangled code produces tangled generations.

What comes next

Events connect contexts, but what happens when the Billing context needs to call the Subscription context directly, not react to an event, but ask a question? That’s where the anti-corruption layer becomes essential. Next: the anti-corruption layer in Go.

The next chapter, Domain-Driven Design: The Anti-Corruption Layer in Go, publishes around 12 Jun.

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