Stage 07 - The Module Pattern
Give each domain a self-wiring unit so main.go stops growing with every feature.
Prereqs: stage 06 complete · Time: ~25 min
By the end: same endpoints, same output - but main.go is a list and a loop.
Why modules
Goal: see why main.go can't stay the wiring hub as domains multiply.
Learn
By stage 06, main.go builds the catalog by hand:
repo := memory.New()
svc := application.NewService(repo)
handlers := cataloghttp.NewHandlers(svc)
handlers.Register(mux)Four lines, one domain. Now project forward. MiniMart is about to grow an order domain (stage 08), and a larger application keeps going - users, payments, inventory, notifications. Each brings a repository, a service, HTTP routes, and later background workers. With this style, every one of those lines lands in main.go, and main.go imports every package of every domain: each memory, application, and presentation/http.
Two costs compound:
main.goknows everyone's internals. It names each domain's concrete adapter and handler types. A change to how the catalog stores data is a change tomain.go.- Nothing is sealed. Any domain's guts are one import away from any other. "Who may call what" becomes a convention people remember, not a boundary the code enforces.
The fix inverts the wiring. Instead of main.go reaching into each domain, each domain exposes one small object that knows how to build and plug in itself. main.go shrinks to a list of those objects and a loop.
That structure has a name: a modular monolith. One binary, one process - but internally, sealed domains standing side by side, each wiring itself and (from stage 09) reaching the others only through published contracts. You get much of the discipline of microservices with none of the network.
This stage adds no endpoint. It restructures the wiring so stages 08–10 have somewhere to stand.
In production: this structure can support many domains in one binary while keeping main.go to a list and a loop; each domain carries its own wiring.
Exercise
Adding indirection with zero new features looks like a bad trade today. When does it pay off?
Answer
At the second domain, and harder at every one after. Without the pattern, each new domain's construction piles into one function, and each new domain becomes importable from every other. The module pattern caps both: main.go grows by one line per domain, and a domain's internals stay reachable only through its own front door.
The Module interface
Goal: define the lifecycle interface every domain implements.
Learn
A module is a domain's self-wiring unit. To let main.go treat every domain identically, they all satisfy one interface. It lives in pkg/ - the home for code shared by all domains and owned by none (plumbing only; no business logic).
The interface declares four methods, called in phases during boot:
Name()- identifies the module in boot logs and errors.Init(ctx)- build the domain's internals (service, adapters).RegisterContracts(ctx, c)- publish this domain's contract into a shared registry so other domains can call it. Empty until stage 09.RegisterHTTP(ctx, mux)- wire this domain's routes onto the mux.
ctx is threaded through even though nothing uses it yet: when Init starts doing work that can block - opening a Postgres pool in stage 11 - cancellation and timeouts already have a lane.
Build
1. Create pkg/module/contracts/contracts.go. It starts as an empty registry; stage 09 fills it with contract slots and a Verify() method:
package contracts
// Contracts is the shared registry modules publish their contracts into. It is
// empty now; stage 09 gives it one slot per published contract, plus Verify().
type Contracts struct{}2. Create pkg/module/module.go:
package module
import (
"context"
"net/http"
"minimart/pkg/module/contracts"
)
// Module is one self-contained domain in the modular monolith.
//
// The boot sequence in main.go calls these methods in phases, for every module:
//
// 1. Init build the module's own internals (service, adapters).
// 2. RegisterContracts publish this module's contract into the shared registry.
// 3. (Verify) main.go fails the boot if any contract slot is still nil.
// 4. RegisterHTTP wire this module's routes onto the mux.
//
// No module may call another module before phase 3 completes.
type Module interface {
Name() string
Init(ctx context.Context) error
RegisterContracts(ctx context.Context, c *contracts.Contracts) error
RegisterHTTP(ctx context.Context, mux *http.ServeMux) error
}Go note: an interface lists method signatures with no bodies. Any type with all four methods satisfiesModuleautomatically - noimplementskeyword, no registration. That is what letsmain.gohold a[]module.Modulewithout importing a single concrete domain type.
The phase comment describes where this is going. This stage wires phases 1, 2, and 4; the Verify() gate (phase 3) arrives in stage 09, when there is a contract to verify.
Grows later: stage 09 givesRegisterContractsa real body, and expands the emptyContractsregistry above. Stage 12 adds a fifth method,RegisterWorkers(ctx, w), for Kafka consumers - the worker process registers those and skips HTTP.
Verify
go build ./pkg/...Prints nothing, exits 0. Nothing implements Module yet - that's the next section.
Exercise
Why does the lifecycle interface live in pkg/module instead of inside catalog?
Answer
The lifecycle is shared plumbing. It describes how the process boots any domain, not what the catalog means. Putting it in pkg/ keeps the domain packages from owning a concept that every domain must satisfy.
A self-wiring domain
Goal: move the catalog's construction into catalog/module.go.
Learn
The wiring main.go did by hand becomes the catalog's own Module. This file sits in the domain's root folder (catalog/module.go, package catalog), above the four layers - it is the catalog's composition root, the one place that names a concrete adapter.
Build
Create catalog/module.go:
// Package catalog wires the catalog domain into the modular monolith. It is the
// domain's composition root: the one place that names concrete adapters.
package catalog
import (
"context"
"net/http"
"minimart/catalog/application"
"minimart/catalog/infrastructure/memory"
cataloghttp "minimart/catalog/presentation/http"
"minimart/pkg/module"
"minimart/pkg/module/contracts"
)
// Module is the catalog domain's self-wiring unit.
type Module struct {
svc *application.Service
handlers *cataloghttp.Handlers
}
var _ module.Module = (*Module)(nil)
func NewModule() *Module { return &Module{} }
func (m *Module) Name() string { return "catalog" }
// Init builds the domain's internals and chooses the concrete storage adapter.
// Swapping storage is a one-line change here; no other layer is touched.
func (m *Module) Init(ctx context.Context) error {
repo := memory.New()
m.svc = application.NewService(repo)
m.handlers = cataloghttp.NewHandlers(m.svc)
return nil
}
// RegisterContracts is a no-op for now. Stage 09 gives it a body that publishes the
// catalog contract into the shared registry so other domains can call it.
func (m *Module) RegisterContracts(ctx context.Context, c *contracts.Contracts) error {
return nil
}
// RegisterHTTP wires the catalog routes onto the mux.
func (m *Module) RegisterHTTP(ctx context.Context, mux *http.ServeMux) error {
m.handlers.Register(mux)
return nil
}The compile-time assertion var _ module.Module = (*Module)(nil) proves at build time that *Module satisfies the interface. Two Module names share that line: module.Module is the interface from pkg/module; the bare Module is the catalog's struct.
Init is where the storage choice is named - memory.New(). That single line is what stage 11 changes to swap in Postgres; no other layer is touched.
RegisterContracts is a no-op for now - the catalog has no published contract yet. Stage 09 gives it a body.
In production:NewModulecan receive shared infrastructure - a Postgres pool, a Kafka producer, and a logger - whileInitpasses those dependencies to sqlc-backed repositories. The structure stays the same as the dependencies grow.
Verify
go build ./...Builds. main.go still wires the catalog the old way - you rewrite it next, and the server keeps serving products.
Exercise
Why can catalog/module.go name the memory adapter while catalog/application cannot?
Answer
catalog/module.go is the catalog's composition root, so selecting concrete implementations is its job. The application layer should depend only on the repository port, which keeps use cases independent of the storage choice.
The boot loop
Goal: rewrite main.go as a loop over modules.
Learn
With the catalog wiring itself, main.go no longer needs to know what a catalog is. It holds a []module.Module and runs each lifecycle phase across the whole slice before starting the next. Running phase by phase - all Inits, then all RegisterContracts, then all RegisterHTTP - rather than fully booting one module before the next, is what lets modules depend on each other from stage 09: by the time routes go live, every module is built and every contract is published.
Build
Rewrite main.go:
package main
import (
"context"
"log"
"net/http"
"minimart/catalog"
"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. It is empty now; stage 09
// gives it slots and a Verify() gate.
registry := &contracts.Contracts{}
modules := []module.Module{
catalog.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 (a no-op until stage 09).
for _, m := range modules {
if err := m.RegisterContracts(ctx, registry); err != nil {
log.Fatalf("register contracts %s: %v", m.Name(), err)
}
}
// Phase 3 - 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)
}
}main.go now imports minimart/catalog - the domain's front gate - and nothing about memory, application, or handler packages. Adding a domain costs one line in the modules slice; stage 08 proves it.
The registry is created and passed through, but it is empty and every RegisterContracts is a no-op today. Stage 09 fills the registry and inserts a Verify() gate between phases 2 and 3 - fail the boot loudly if a module forgot to register, rather than nil-panic on the first request.
In production: the boot ceremony also has a closing act - trapSIGTERM, callhttp.Server.Shutdown, let in-flight requests finish before exit.log.Fatalis fine for MiniMart; the ceremony is symmetric.
Checkpoint
go run .In a second terminal, your stage-06 curls still pass - same endpoints, same output:
curl -s -X POST http://localhost:8080/v1/products \
-d '{"name":"Widget","price_cents":1299,"stock":10}'
# → the created product as JSON
curl -s -i http://localhost:8080/v1/products/ghost | head -1
# → HTTP/1.1 404 Not FoundWhat you learned
- Each domain wires itself;
main.gois a list and a loop. - Boot runs in phases across all modules, not one module at a time.
pkg/holds shared, business-free plumbing.
Exercise
Why run all modules through Init, then all through RegisterContracts, then all through RegisterHTTP?
Answer
Phase-by-phase boot lets every domain prepare its internals before any domain asks for shared contracts or registers an edge. That matters once modules depend on one another: the process gets one predictable lifecycle instead of many private boot orders.