MiniCart

Stage 09 - Publish a Contract

Let one domain call another without importing its internals.

Prereqs: stage 08 complete · Time: ~30 min

By the end: the catalog publishes a contract, main.go boots in four phases, and a missing registration crashes at startup - not on the first request.


The forbidden import

Goal: understand why a domain may not import another domain's internals.

Learn

The order service needs a product's price. The shortcut is one import:

Go
import "minimart/catalog/application" // don't

It compiles. That is the danger. With it, the order domain now depends on the catalog's concrete service, its domain types, and its errors - its entire inner world. Two domains that were separate are now welded: you cannot change the catalog's internals without risking the order code, and you cannot test order without dragging the whole catalog in. Later, if orders and catalog ever need to call each other, the mutual import becomes a compile-time cycle Go refuses outright.

The rule:

A domain may never import another domain's application/, domain/, or infrastructure/. It may import only the other domain's published contract.

A contract is a tiny package a domain exposes for others to call: one interface, plain data types, published errors - nothing else. It is the domain's front door. Everything behind it stays private and free to change.

Nothing in the compiler enforces this - the forbidden import builds fine. It is a discipline (and, in production, a custom linter that fails the build on a cross-domain import into anything but a client package).

The later sections build the catalog's contract; stage 10 has the order domain consume it.

This is why a modular monolith is a useful training ground. Splitting code into microservices does not fix muddy boundaries; it only adds a network to them. A published contract gives you the same discipline while refactoring is still cheap.

Exercise

The forbidden import compiles and works today. Why treat it as a hard rule anyway?

Answer

Because "works today" is the trap. Once one domain reaches into another's guts, every later change to those guts can break it, silently and at a distance. The contract is the promise that stays stable while everything behind it moves. A wall you can see is cheaper than a coupling you can't.


The client package

Goal: publish the catalog's contract - an interface, a DTO, an error.

Learn

The contract lives at catalog/presentation/module/client/. It is the only part of the catalog other domains may import. It holds no entities, no service, no infrastructure - only what a caller needs: the Catalog interface, a dependency-free Product view, and a client-level ErrProductNotFound sentinel (so callers never import the catalog's domain package for its errors).

It sits under presentation/ because it is another input adapter - a way the outside (here, another module) drives the catalog - beside the HTTP edge.

Build

Create catalog/presentation/module/client/client.go:

Go
// Package client is the only part of the catalog domain other domains may import.
// It exposes the published contract interface and its plain data types - no
// entities, no application services, no infrastructure.
package client

import (
	"context"
	"errors"
)

// ErrProductNotFound is returned by the contract when no product has the given ID.
// It is a client-level sentinel so callers never import the catalog's domain package.
var ErrProductNotFound = errors.New("catalog: product not found")

// Catalog is the contract the catalog domain publishes to the rest of the system.
type Catalog interface {
	GetProduct(ctx context.Context, id string) (Product, error)
}

// Product is the dependency-free view of a catalog product passed across domains.
type Product struct {
	ID         string
	Name       string
	PriceCents int64
}

Product carries no JSON tags: it crosses a Go call boundary, not the wire. It is deliberately separate from both domain.Product and the HTTP DTO, so each can change without breaking the others. It also omits fields a caller doesn't need (Stock, CreatedAt) - the contract exposes the minimum.

You now have three product shapes: the domain entity, the HTTP response, and this cross-domain client.Product. They look similar because MiniMart is small, but they serve different promises. If catalog later becomes a separate service, this contract is the thing that turns into OpenAPI or protobuf; the order domain should not need to learn catalog internals then either.

Verify

Shell
go build ./...

Builds. Nothing implements Catalog yet - that's next.

Exercise

Why keep a separate client.Product when it currently looks much like the domain entity and the HTTP response?

Answer

Each shape makes a different promise. The domain entity protects catalog invariants, the HTTP response is a public wire contract, and client.Product is the cross-domain contract order can import. Keeping them separate gives each boundary room to change.


The adapter

Goal: implement the published contract over the catalog service.

Learn

The contract is an interface; something must satisfy it. That something is an adapter in catalog/presentation/module - it holds the catalog's application service and translates between domain types and the published client types. It is the bridge from the front door to the domain's insides, and the only code that crosses that line.

Build

1. Create catalog/presentation/module/module.go:

Go
// Package module adapts the catalog application service to the published
// client.Catalog contract. It is the only path other domains use to reach the
// catalog domain - they never touch its application, domain, or infrastructure.
package module

import (
	"minimart/catalog/application"
	"minimart/catalog/presentation/module/client"
)

// Catalog implements client.Catalog by delegating to the application service and
// translating domain types into the published client types.
type Catalog struct {
	svc *application.Service
}

// Compile-time proof this adapter satisfies the published contract.
var _ client.Catalog = (*Catalog)(nil)

func New(svc *application.Service) *Catalog {
	return &Catalog{svc: svc}
}

2. Create catalog/presentation/module/get_product.go:

Go
package module

import (
	"context"
	"errors"

	"minimart/catalog/domain"
	"minimart/catalog/presentation/module/client"
)

// GetProduct is the cross-domain entry point. It calls the service, then maps the
// domain result and error into the published client types, so callers depend only
// on the client package.
func (c *Catalog) GetProduct(ctx context.Context, id string) (client.Product, error) {
	p, err := c.svc.GetProduct(ctx, id)
	if err != nil {
		if errors.Is(err, domain.ErrProductNotFound) {
			return client.Product{}, client.ErrProductNotFound
		}
		return client.Product{}, err
	}
	return client.Product{ID: p.ID, Name: p.Name, PriceCents: p.PriceCents}, nil
}

var _ client.Catalog = (*Catalog)(nil) proves the adapter satisfies the contract. GetProduct calls the service, then maps the result: domain.Productclient.Product, and domain.ErrProductNotFoundclient.ErrProductNotFound. Callers depend only on the client package - never on the catalog's domain.

In production: this same file opens an observability span before calling the service, so cross-module hops show on the request trace, and wraps the error on the way out so a failure records that it crossed a module boundary. The skeleton is identical: call, translate types, translate errors.

Verify

Shell
go build ./...

Builds.

Exercise

Why translate domain.ErrProductNotFound into client.ErrProductNotFound here?

Answer

The caller should depend on the published client package, not catalog's domain package. Translating the sentinel at this adapter keeps the cross-domain contract stable even if catalog later reshapes its internal errors.


The registry

Goal: build the shared registry domains call each other through.

Learn

A caller shouldn't hunt down each domain's adapter. Instead, one shared registry holds every published contract, and callers reach them all through it. Two types work together:

  • Modules - the flat interface consumers depend on. It embeds each domain's contract, so calls read s.modules.GetProduct(...), not s.modules.Catalog.GetProduct(...).
  • Contracts - the concrete struct main.go builds and fills. Each module writes its adapter into its slot during RegisterContracts.

Struct for wiring, interface for consumption: main.go builds the concrete Contracts, while application services depend on the Modules interface so tests can pass a fake.

Verify() fails the boot if any slot was never filled - turning "someone forgot to register" from a nil-pointer panic on the first request into a loud crash at startup.

Build

Expand pkg/module/contracts/contracts.go from the empty stub of stage 07 into:

Go
package contracts

import (
	"errors"

	catalog "minimart/catalog/presentation/module/client"
)

// Modules is the flat interface a domain calls other domains through. It embeds
// each domain's published contract, so callers write s.modules.GetProduct(...),
// not s.modules.Catalog.GetProduct(...).
//
// Application services depend on this INTERFACE (so tests can substitute a fake),
// while main.go builds the concrete *Contracts below.
type Modules interface {
	catalog.Catalog
}

// Contracts is the live registry that satisfies Modules. Each module writes its
// own adapter into its slot during the RegisterContracts phase. Because every
// module receives the same pointer, all slots are filled before any request runs.
type Contracts struct {
	catalog.Catalog
}

// Verify fails the boot if any contract slot was never filled. Calling it after
// RegisterContracts turns "someone forgot to register" from a nil-pointer panic
// on the first request into a loud crash at startup.
func (c *Contracts) Verify() error {
	if c.Catalog == nil {
		return errors.New("contracts: catalog not registered")
	}
	return nil
}
Go note: writing a type name alone inside a struct or interface is embedding. In Contracts, embedding catalog.Catalog promotes its GetProduct method onto Contracts; in the Modules interface, it merges the method set. That is what makes cross-domain calls flat. An embedded interface field nobody assigns holds nil, and calling through it panics - exactly the hole Verify plugs.

The import is aliased catalog (the last path element, client, would read badly). Verify checks the one slot; each new published contract adds a field and a matching check.

Verify

Shell
go build ./...

Builds - but main.go doesn't fill or verify the registry yet. That's the last section.

Exercise

Why is Contracts a concrete struct, while consumers depend on the Modules interface?

Answer

The boot code needs something concrete to fill: each module writes its adapter into a slot. Consumers need only the behavior they call, so they accept the interface. That split keeps construction explicit and use cases easy to fake in tests.


Four-phase boot

Goal: register the contract, verify it at boot, and prove the gate works.

Learn

Now the phases from stage 07 pay off. The catalog fills its registry slot in RegisterContracts; main.go calls Verify() after every module has registered and before any route is live. The order is load-bearing: contracts are published in phase 2, checked in phase 3, and only in phase 4 do the doors open - so no request can hit a half-wired registry.

Build

1. Give catalog/module.go's RegisterContracts a real body, and add the catalogmodule import:

Go
	catalogmodule "minimart/catalog/presentation/module"
Go
// RegisterContracts publishes the catalog contract into the shared registry.
func (m *Module) RegisterContracts(ctx context.Context, c *contracts.Contracts) error {
	c.Catalog = catalogmodule.New(m.svc)
	return nil
}

2. In main.go, insert the Verify() gate between RegisterContracts and RegisterHTTP, so the loop now runs four phases:

Go
	// Phase 3 - fail the boot now if any contract slot is still nil.
	if err := registry.Verify(); err != nil {
		log.Fatalf("boot: %v", err)
	}

<details><summary>Reference: the full four-phase main.go</summary>

Go
package main

import (
	"context"
	"log"
	"net/http"

	"minimart/catalog"
	"minimart/order"
	"minimart/pkg/module"
	"minimart/pkg/module/contracts"
)

func main() {
	ctx := context.Background()

	mux := http.NewServeMux()

	// /health lives in the composition root, not a domain: it answers "is this
	// process up?", an operational question, not a business capability.
	mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"status":"ok"}`))
	})

	// One registry pointer, shared by every module. Slots are empty now and
	// filled during RegisterContracts, before any request is served.
	registry := &contracts.Contracts{}

	modules := []module.Module{
		catalog.NewModule(),
		order.NewModule(),
	}

	// Phase 1 - build each module's internals.
	for _, m := range modules {
		if err := m.Init(ctx); err != nil {
			log.Fatalf("init %s: %v", m.Name(), err)
		}
	}
	// Phase 2 - each module publishes its contract into the registry.
	for _, m := range modules {
		if err := m.RegisterContracts(ctx, registry); err != nil {
			log.Fatalf("register contracts %s: %v", m.Name(), err)
		}
	}
	// Phase 3 - fail the boot now if any contract slot is still nil.
	if err := registry.Verify(); err != nil {
		log.Fatalf("boot: %v", err)
	}
	// Phase 4 - wire HTTP routes.
	for _, m := range modules {
		if err := m.RegisterHTTP(ctx, mux); err != nil {
			log.Fatalf("register http %s: %v", m.Name(), err)
		}
	}

	log.Println("minimart listening on http://localhost:8080")
	if err := http.ListenAndServe(":8080", mux); err != nil {
		log.Fatal(err)
	}
}

(The order module still constructs with order.NewModule(); it consumes no contract until stage 10, which changes that call to pass the registry.)

Checkpoint

Shell
go run .

Products and orders still work. Now sabotage it: comment out the c.Catalog = catalogmodule.New(m.svc) line in catalog/module.go (and the catalogmodule import, which Go would flag as unused), then run again:

Shell
go run .
# boot: contracts: catalog not registered

The process refuses to start. Restore both lines. Without Verify(), that same mistake would boot fine and, once stage 10 has the order domain consume the catalog, nil-panic on the first order that needs the catalog - a failure at request time instead of at boot.

In production: every dependency that can be checked before serving - a database ping, a required config value, a contract slot - is verified at boot for exactly this reason: fail loudly at startup, not silently on a customer's first request.

What you learned

  • A domain publishes a contract (interface + DTO + error) under presentation/module/client; nothing else may cross domains.
  • The shared registry is a struct to build and an interface to consume.
  • Verify() makes a forgotten registration a boot crash, not a runtime panic.

Exercise

What kind of failure moves from request time to boot time because of Verify()?

Answer

A missing contract registration. Without Verify, the process can start with a nil registry slot and only panic when the first request crosses that boundary. With Verify, the process refuses to serve until the graph is complete.