Domain-Driven Design: The Anti-Corruption Layer in Go

June 12, 2026 · 15 min read

Tom’s code compiles. All the tests pass. And Charlotte is frowning at his screen. “You imported the subscription package into billing,” she says. “That’s a crack in the wall.”

Two weeks into the bounded context refactor. The event-driven communication is working. Subscription publishes events; Billing and Supply Matching listen. No direct dependencies between contexts.

Then Priya hits a real problem. The Supply Matching context needs to know the box size for a subscription, not when the subscription is created (the event handles that), but right now, when matching farms to demand. A customer changed their box size last week. The event was published and processed. But Supply Matching’s local copy of the box size didn’t update correctly. A race condition in the event handler.

“I’ll just import the subscription package and call FindByID,” Priya says.

Charlotte stops her. “That’s the thing that feels natural and costs you later.”

Why direct imports are a problem

If supplymatching imports subscription, the bounded context boundary is gone. The Go compiler will let it. The dependency will compile. And then:

  • When the Subscription entity changes its internal representation, Supply Matching breaks.
  • When the subscription package adds a dependency (say, a new database driver), Supply Matching transitively depends on it.
  • When an LLM generates code in the Supply Matching context with the subscription package in its context window, it starts using Subscription types to solve Supply Matching problems. The languages blur.
  • When the team decides to extract Supply Matching into a separate service, the import is a concrete dependency that has to be rewritten.

“It’s not about today,” Charlotte says. “Today, it’s one import. In six months, it’s forty imports and you’re back to the monolith.”

The anti-corruption layer

The anti-corruption layer (ACL) is a translation boundary. It lets one context ask questions of another without importing the other’s types or depending on its internal model.

In Go, it’s an interface defined by the consuming context, the context that needs the information:

// file: supplymatching/supplymatching.go
package supplymatching

type SubscriptionInfo struct {
	SubscriptionID string
	BoxSize        string
	IsActive       bool
}

type SubscriptionLookup interface {
	ActiveSubscription(ctx context.Context, subscriptionID string) (SubscriptionInfo, error)
}

Notice what’s happening. Supply Matching defines its own SubscriptionInfo struct. Not the Subscription context’s Subscription entity. A flat struct with exactly the fields Supply Matching needs, using Supply Matching’s own types. No subscription.BoxSize, just a string. No subscription.SubscriptionID, just a string.

Supply Matching also defines the interface it needs: SubscriptionLookup. One method, one purpose. The Subscription context doesn’t know this interface exists. It doesn’t implement it directly. Instead, an adapter in the infrastructure layer bridges the gap:

// file: adapters/subscription_adapter.go
package adapters

import (
	"context"
	"greenbox/subscription"
	"greenbox/supplymatching"
)

type SubscriptionAdapter struct {
	repo subscription.Repository
}

func NewSubscriptionAdapter(repo subscription.Repository) *SubscriptionAdapter {
	return &SubscriptionAdapter{repo: repo}
}

func (a *SubscriptionAdapter) ActiveSubscription(
	ctx context.Context,
	subscriptionID string,
) (supplymatching.SubscriptionInfo, error) {
	sub, err := a.repo.FindByID(ctx, subscription.SubscriptionID(subscriptionID))
	if err != nil {
		return supplymatching.SubscriptionInfo{}, err
	}
	return supplymatching.SubscriptionInfo{
		SubscriptionID: string(sub.ID()),
		BoxSize:        sub.BoxSize().String(),
		IsActive:       sub.IsActive(),
	}, nil
}

The adapter lives in the infrastructure layer, not in either bounded context. It imports both packages, but neither context imports the other. The dependency flows through infrastructure, not through the domain.

supplymatching ←── adapters ──→ subscription
(defines interface)  (implements)  (provides data)

Supply Matching depends on its own interface. The adapter depends on both. Subscription depends on nothing. If Supply Matching becomes a separate service tomorrow, the adapter is replaced with an HTTP client that calls the Subscription service’s API. The SubscriptionLookup interface stays the same. The Supply Matching domain code doesn’t change at all.

Wiring the adapter

In main.go, the adapter is created and injected:

// file: main.go
package main

import (
	"greenbox/adapters"
	"greenbox/subscription"
	"greenbox/supplymatching"
)

func main() {
	subRepo := subscription.NewPostgresRepository(db)
	subAdapter := adapters.NewSubscriptionAdapter(subRepo)

	matchingService := supplymatching.NewService(
		supplymatching.NewPostgresRepository(db),
		subAdapter, // satisfies supplymatching.SubscriptionLookup
	)
	// ...
}

The Supply Matching service receives its dependency through the interface it defined. It doesn’t know whether the data comes from a database, an HTTP call, or a test fake. It asked for SubscriptionLookup and got one.

The test double

Testing Supply Matching without a real Subscription context is trivial:

// file: supplymatching/supplymatching_test.go
package supplymatching_test

import (
	"context"
	"greenbox/supplymatching"
	"testing"
)

type stubSubscriptionLookup struct {
	info supplymatching.SubscriptionInfo
	err  error
}

func (s *stubSubscriptionLookup) ActiveSubscription(
	_ context.Context, _ string,
) (supplymatching.SubscriptionInfo, error) {
	return s.info, s.err
}

func TestMatchingUsesBoxSize(t *testing.T) {
	lookup := &stubSubscriptionLookup{
		info: supplymatching.SubscriptionInfo{
			SubscriptionID: "sub-1",
			BoxSize:        "large",
			IsActive:       true,
		},
	}
	svc := supplymatching.NewService(
		supplymatching.NewInMemoryRepository(),
		lookup,
	)

	ctx := context.Background()
	allocation, err := svc.AllocateForSubscription(ctx, "sub-1")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if allocation.BoxSize != "large" {
		t.Errorf("expected large, got %s", allocation.BoxSize)
	}
}

func TestMatchingSkipsInactiveSubscription(t *testing.T) {
	lookup := &stubSubscriptionLookup{
		info: supplymatching.SubscriptionInfo{
			SubscriptionID: "sub-2",
			BoxSize:        "medium",
			IsActive:       false,
		},
	}
	svc := supplymatching.NewService(
		supplymatching.NewInMemoryRepository(),
		lookup,
	)

	ctx := context.Background()
	_, err := svc.AllocateForSubscription(ctx, "sub-2")
	if err == nil {
		t.Error("expected error for inactive subscription")
	}
}

The stub returns exactly what the test needs. No database. No Subscription context running. The test proves Supply Matching’s logic in isolation.

When an ACL is not an ACL

Tom writes a “shortcut” ACL that looks like this:

// file: adapters/subscription_adapter.go -- DON'T DO THIS
// Don't do this
func (a *SubscriptionAdapter) GetSubscription(
	ctx context.Context,
	id string,
) (*subscription.Subscription, error) {
	return a.repo.FindByID(ctx, subscription.SubscriptionID(id))
}

Charlotte catches it in review. “This returns the Subscription entity. Supply Matching now has the full entity, every method, every field accessor. The boundary is gone. An ACL translates. It doesn’t pass through.”

The fix is what they had before: return supplymatching.SubscriptionInfo, not *subscription.Subscription. Translate at the boundary. Strip everything the consuming context doesn’t need. Expose only what’s relevant.

“Think of it as a customs checkpoint,” Charlotte says. “You declare what you’re bringing in. Only approved items cross.”

When events aren’t enough: the query pattern

Events are great for “something happened, react to it.” But Supply Matching’s problem is different: “I need to know the current state right now.” That’s a query, not an event.

The team settles on a rule:

  • Events for reactions: “A subscription was created. Billing, create an invoice.”
  • Queries through ACL for current state: “What box size does subscription X have right now?”

Both patterns keep the contexts decoupled. Events flow one way (publisher doesn’t know the consumer). Queries flow through interfaces (consumer defines what it needs, adapter provides it).

// Events: fire-and-forget, publisher doesn't care who listens
bus.Subscribe("subscription.created", billingHandlers.OnSubscriptionCreated)

// Queries: consumer asks, adapter answers
matchingService := supplymatching.NewService(repo, subAdapter)

The distinction: events carry data at the time of the event. Queries return data at the time of the query. Both are needed. Using only events means every context maintains a local copy of data from other contexts, and keeping those copies consistent is its own challenge. Using only queries means tight coupling and chatty communication. The correct mix depends on the domain.

For Greenbox, the team uses events for state changes (new subscription, cancellation, box size change) and queries for point-in-time lookups during supply matching runs. The supply matching algorithm runs weekly. It doesn’t need real-time data from Subscription, it needs accurate data at the moment it runs.

The pattern for Fulfilment

Fulfilment needs different data. It needs the delivery address and the box contents. Neither of these come from Subscription, they come from Billing (who confirmed payment) and Supply Matching (who allocated produce).

// file: fulfilment/fulfilment.go
package fulfilment

type DeliveryOrder struct {
	OrderID        string
	SubscriptionID string
	Address        DeliveryAddress
	Items          []BoxItem
	ScheduledDate  time.Time
}

type DeliveryAddress struct {
	Line1    string
	Line2    string
	City     string
	State    string
	Postcode string
}

type BoxItem struct {
	ProduceName string
	Quantity    int
	Unit        string
	FarmName    string
}

type PaymentConfirmation interface {
	IsPaymentConfirmed(ctx context.Context, subscriptionID string) (bool, error)
}

type BoxAllocation interface {
	AllocationForSubscription(ctx context.Context, subscriptionID string) ([]BoxItem, error)
}

Fulfilment defines PaymentConfirmation and BoxAllocation, the questions it needs answered. Adapters bridge to Billing and Supply Matching respectively. Fulfilment never imports either package.

// file: adapters/payment_adapter.go
package adapters

import (
	"context"
	"greenbox/billing"
	"greenbox/fulfilment"
)

type PaymentAdapter struct {
	invoices billing.InvoiceRepository
}

func NewPaymentAdapter(invoices billing.InvoiceRepository) *PaymentAdapter {
	return &PaymentAdapter{invoices: invoices}
}

func (a *PaymentAdapter) IsPaymentConfirmed(
	ctx context.Context,
	subscriptionID string,
) (bool, error) {
	inv, err := a.invoices.FindBySubscriptionRef(
		ctx, billing.SubscriptionRef(subscriptionID),
	)
	if err != nil {
		return false, err
	}
	return inv.Status() == "paid", nil
}

The adapter translates Billing’s Invoice into Fulfilment’s question: “is payment confirmed?” Fulfilment doesn’t know about invoices, statuses, or Stripe. It knows about delivery orders and whether to dispatch them.

The package dependency graph

After four weeks, the dependency graph looks like this:

main.go (composition root)
├── imports subscription
├── imports billing
├── imports supplymatching
├── imports fulfilment
├── imports adapters
└── imports eventbus

adapters
├── imports subscription
├── imports billing
├── imports supplymatching
└── imports fulfilment

subscription → (no domain imports)
billing → (no domain imports)
supplymatching → (no domain imports)
fulfilment → (no domain imports)

No domain package imports another domain package. The adapters package is the only place where multiple domains appear together. main.go wires everything.

Tom draws this on the whiteboard. “Six months ago, every file imported every other file. Now the domains are islands.”

“Islands with ferry services,” Kai adds, pointing at the adapters.

What the ACL protects against

Three weeks later, the team proves Charlotte correct. Priya refactors the Subscription entity’s internal representation. The BoxSize type changes from an int enum to a struct with additional fields, a name, a volume in litres, and a weight limit.

// file: subscription/subscription.go
type BoxSize struct {
	name        string
	volumeLitres int
	weightLimitKg int
}

The Subscription context’s tests break. The Subscription context’s code is updated. The adapter’s translation function changes to use sub.BoxSize().Name() instead of sub.BoxSize().String().

Nothing else changes. Billing doesn’t know. Supply Matching doesn’t know. Fulfilment doesn’t know. The ACL absorbed the change at the boundary.

“That refactor would have touched every package in the old codebase,” Tom says. He’s not complaining about the walls any more.

Charlotte’s rule of thumb

At the end of the sprint, Charlotte writes a rule on the whiteboard that stays there for months:

If you’re importing another context’s package, you’re removing a wall. Use an adapter. Define the interface where it’s consumed. Translate at the boundary. Let each context own its own language.

The team adds it to their ADR collection as ADR-017: Inter-context communication must go through adapters or events. Direct package imports between bounded contexts are not permitted.

Kai configures a linter rule to flag cross-context imports. The CI pipeline fails if billing imports subscription. The boundary isn’t just a convention, it’s enforced by the build.

The long game

Six months later, when Greenbox expands to Melbourne and the team decides to extract Supply Matching into a separate service, the extraction is straightforward. The SubscriptionLookup interface that Supply Matching already depends on gets a new implementation, an HTTP client instead of a direct adapter:

// file: adapters/http_subscription_lookup.go
package adapters

import (
	"context"
	"encoding/json"
	"fmt"
	"greenbox/supplymatching"
	"net/http"
)

type HTTPSubscriptionLookup struct {
	baseURL string
	client  *http.Client
}

func NewHTTPSubscriptionLookup(baseURL string) *HTTPSubscriptionLookup {
	return &HTTPSubscriptionLookup{
		baseURL: baseURL,
		client:  &http.Client{},
	}
}

func (h *HTTPSubscriptionLookup) ActiveSubscription(
	ctx context.Context,
	subscriptionID string,
) (supplymatching.SubscriptionInfo, error) {
	url := fmt.Sprintf("%s/subscriptions/%s", h.baseURL, subscriptionID)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return supplymatching.SubscriptionInfo{}, err
	}
	resp, err := h.client.Do(req)
	if err != nil {
		return supplymatching.SubscriptionInfo{}, err
	}
	defer resp.Body.Close()

	var info supplymatching.SubscriptionInfo
	if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
		return supplymatching.SubscriptionInfo{}, err
	}
	return info, nil
}

The Supply Matching domain code doesn’t change. Not one line. The interface it defined months ago – SubscriptionLookup, now has a different implementation behind it. The adapter absorbed the architectural change the same way it absorbed the internal refactor.

Tom tells this story at a Go meetup in Perth. Someone asks, “Wasn’t all that interface ceremony worth it?”

“I didn’t think so at the time,” Tom says. “Now I think it’s the cheapest insurance we ever bought.”

The three posts together

Looking back at this series: modelling the domain gave us entities and value objects that enforce business rules through the type system. Events let bounded contexts communicate without coupling. And the anti-corruption layer protects each context’s language and lets the architecture evolve without rewriting the domain.

None of this required a framework. No DDD library. No event sourcing infrastructure. Just Go packages, interfaces, and the discipline to translate at the boundary.

Charlotte’s parting observation: “DDD isn’t a technology choice. It’s a communication choice. The code mirrors how the team talks about the business. When the team says ‘subscription’ and means four different things, the code should have four different types in four different packages. The compiler enforces the conversations you had at the whiteboard.”

The whiteboard still has four boxes on it. But now the code matches.

What comes next

The substitution rules that Maya carries in her head need to scale to Melbourne, and to a team member who doesn’t have twenty years of farming knowledge. Charlotte reaches for decision tables.

The next chapter, Decision Tables: Making Maya's Brain Explicit, publishes around 23 June.

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