MiniCart

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:

Go
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.go knows everyone's internals. It names each domain's concrete adapter and handler types. A change to how the catalog stores data is a change to main.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:

Go
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:

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 satisfies Module automatically - no implements keyword, no registration. That is what lets main.go hold a []module.Module without 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 gives RegisterContracts a real body, and expands the empty Contracts registry above. Stage 12 adds a fifth method, RegisterWorkers(ctx, w), for Kafka consumers - the worker process registers those and skips HTTP.

Verify

Shell
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:

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: NewModule can receive shared infrastructure - a Postgres pool, a Kafka producer, and a logger - while Init passes those dependencies to sqlc-backed repositories. The structure stays the same as the dependencies grow.

Verify

Shell
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:

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 - trap SIGTERM, call http.Server.Shutdown, let in-flight requests finish before exit. log.Fatal is fine for MiniMart; the ceremony is symmetric.

Checkpoint

Shell
go run .

In a second terminal, your stage-06 curls still pass - same endpoints, same output:

Shell
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 Found

What you learned

  • Each domain wires itself; main.go is 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.