MiniCart
Stage 10 The Handshake

Stage 10 - The Handshake

Have the order domain consume the catalog contract, so prices come from the catalog.

Prereqs: stage 09 complete · Time: ~25 min

By the end: place an order with only product_id and quantity; the total is computed from catalog prices, and an unknown product returns 400.


Consume a contract

Goal: give the order service access to the catalog through the registry.

Learn

The order service gains a second dependency: contracts.Modules. Note the type - the interface, not the concrete *contracts.Contracts struct. A consumer depends on the interface so a test can hand it a fake catalog; only main.go builds the concrete registry.

There is a timing subtlety. The order module receives the registry pointer at construction, while its slots are still empty (they fill during RegisterContracts in phase 2). That is safe because the service only dereferences the registry at request time - long after phase 3 has verified every slot is filled. One registry, injected early, read late.

Because Modules embeds the catalog contract, the call reads flat: s.modules.GetProduct(ctx, id), not s.modules.Catalog.GetProduct(...).

Build

Rewrite order/application/service.go:

Go
package application

import (
	"minimart/order/domain"
	"minimart/pkg/module/contracts"
)

// Service is the order application layer. It depends on the order repository port
// and on other domains through the contracts.Modules INTERFACE - never on their
// concrete packages. Depending on the interface is what lets tests pass a fake.
type Service struct {
	repo    domain.Repository
	modules contracts.Modules
}

func NewService(repo domain.Repository, modules contracts.Modules) *Service {
	return &Service{repo: repo, modules: modules}
}

Verify

Shell
go build ./order/application/...

The application package compiles. The whole binary is temporarily red - NewService now needs a second argument that the order module supplies in the Cross-domain errors section. You finish PlaceOrder next, then wire the registry through, then run it end to end.

Grows later: stage 12 adds a third argument - a Kafka publisher - so the service can emit an OrderPlaced event after saving. The dependency stays an interface, for the same reason modules is one.

Exercise

Why should Service accept contracts.Modules instead of *contracts.Contracts?

Answer

The service only needs the behavior promised by the interface. Depending on the concrete registry would tie the order application to boot-time storage details and make tests construct more than they use.


Real prices

Goal: resolve prices from the catalog and remove them from the request.

Learn

With the registry in hand, PlaceOrder fetches each product through the contract and snapshots the catalog's name and price into the order item. The caller now sends only a product ID and a quantity. A price the order domain did not compute is no longer something it will accept.

That is more than validation; it is an authority decision. The client can request a purchase, but the catalog owns the current price. The order owns the historical snapshot once the order is placed. Naming who owns a fact is often the architecture.

Build

1. Rewrite order/application/place_order.go:

Go
package application

import (
	"context"
	"fmt"

	"minimart/order/domain"
)

// PlaceOrderRequest is the input to PlaceOrder. It carries NO prices or names:
// those are authoritative from the catalog, fetched below. The client only says
// which product and how many.
type PlaceOrderRequest struct {
	UserID string
	Items  []PlaceOrderItem
}

type PlaceOrderItem struct {
	ProductID string
	Quantity  int
}

// PlaceOrder resolves each product's real price from the catalog (through the
// published contract), builds the order, and saves it. The order domain trusts
// the catalog for prices, not the caller.
func (s *Service) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (domain.Order, error) {
	if len(req.Items) == 0 {
		return domain.Order{}, domain.ErrNoItems
	}
	items := make([]domain.Item, 0, len(req.Items))
	for _, it := range req.Items {
		if it.Quantity <= 0 {
			return domain.Order{}, domain.ErrInvalidQuantity
		}
		p, err := s.modules.GetProduct(ctx, it.ProductID)
		if err != nil {
			return domain.Order{}, fmt.Errorf("resolve product %s: %w", it.ProductID, err)
		}
		items = append(items, domain.Item{
			ProductID:      p.ID,
			Name:           p.Name,
			UnitPriceCents: p.PriceCents,
			Quantity:       it.Quantity,
		})
	}
	order, err := domain.NewOrder(newID(), req.UserID, items)
	if err != nil {
		return domain.Order{}, err
	}
	if err := s.repo.Save(ctx, order); err != nil {
		return domain.Order{}, err
	}
	return order, nil
}

PlaceOrderItem loses Name and UnitPriceCents. Each item's price comes from s.modules.GetProduct; a lookup failure is wrapped with %w, so the sentinel survives for the edge to classify.

2. Strip the wire DTO in order/presentation/http/dto.go - placeOrderItem keeps only product_id and quantity:

Go
// placeOrderRequest is the body of POST /v1/orders. Note there is no price field:
// the client cannot set prices. It only names products and quantities.
type placeOrderRequest struct {
	UserID string           `json:"user_id"`
	Items  []placeOrderItem `json:"items"`
}

type placeOrderItem struct {
	ProductID string `json:"product_id"`
	Quantity  int    `json:"quantity"`
}

(The response types - orderResponse, itemResponse, and toOrderResponse - are unchanged from stage 08.)

3. Update the handler order/presentation/http/place_order.go to map only the two surviving fields:

Go
func (h *Handlers) PlaceOrder(w http.ResponseWriter, r *http.Request) {
	var req placeOrderRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, errorResponse{"invalid JSON body"})
		return
	}
	items := make([]application.PlaceOrderItem, 0, len(req.Items))
	for _, it := range req.Items {
		items = append(items, application.PlaceOrderItem{ProductID: it.ProductID, Quantity: it.Quantity})
	}
	o, err := h.svc.PlaceOrder(r.Context(), application.PlaceOrderRequest{
		UserID: req.UserID,
		Items:  items,
	})
	if err != nil {
		respondDomainError(w, err)
		return
	}
	writeJSON(w, http.StatusCreated, toOrderResponse(o))
}

Verify

Shell
go build ./order/application/... ./order/presentation/http/...

Green. The full binary goes green in the Cross-domain errors section, once the registry is threaded into the order module.

In production: the wire shape isn't pruned by hand at all - handlers are generated from an OpenAPI spec, so the schema pins which fields exist. Dropping a field here is the teaching version of that guarantee.

Exercise

Which layer lost the authority to provide prices in this section?

Answer

The HTTP edge. The request DTO no longer accepts name or unit_price_cents, and the application request carries only product IDs and quantities. The order use case now asks the catalog contract for the price owner’s value.


Cross-domain errors

Goal: map a missing product to 400 and thread the registry through the module.

Learn

A product ID the caller named that doesn't exist is a bad request - the caller's input was wrong - not a 404 on the order resource. The order edge already owns error translation; it gains one case for the catalog contract's ErrProductNotFound. This is why the same "not found" is a 404 on GET /v1/products/{id} but a 400 during order placement: only the edge has the context to decide what a failure means.

Then the wiring: the order module takes the shared registry and passes it into the service, and main.go hands it over at construction.

Build

1. Add the catalog case to order/presentation/http/response.go:

Go
package http

import (
	"encoding/json"
	"errors"
	"net/http"

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

func writeJSON(w http.ResponseWriter, status int, body any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(body)
}

type errorResponse struct {
	Error string `json:"error"`
}

// respondDomainError maps order-domain errors AND the catalog contract's
// ErrProductNotFound to HTTP status codes. A product the caller named that does
// not exist is a bad request (the caller's input was wrong), not a 404 on the
// order resource.
func respondDomainError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, domain.ErrOrderNotFound):
		writeJSON(w, http.StatusNotFound, errorResponse{err.Error()})
	case errors.Is(err, domain.ErrNoItems),
		errors.Is(err, domain.ErrInvalidQuantity),
		errors.Is(err, catalogclient.ErrProductNotFound):
		writeJSON(w, http.StatusBadRequest, errorResponse{err.Error()})
	default:
		writeJSON(w, http.StatusInternalServerError, errorResponse{"internal error"})
	}
}

That catalogclient import is an order file reaching into catalog/… - legal because the path is presentation/module/client, the one published package.

2. Rewrite order/module.go to receive and pass the registry:

Go
package order

import (
	"context"
	"net/http"

	"minimart/order/application"
	"minimart/order/infrastructure/memory"
	orderhttp "minimart/order/presentation/http"
	"minimart/pkg/module"
	"minimart/pkg/module/contracts"
)

// Module wires the order domain. It holds the shared contracts registry so its
// service can call other domains through it.
type Module struct {
	registry *contracts.Contracts
	svc      *application.Service
	handlers *orderhttp.Handlers
}

var _ module.Module = (*Module)(nil)

// NewModule receives the shared registry pointer. Its slots are empty now and
// filled during RegisterContracts, before any request runs - so it is safe to
// stash here and call later from request handlers.
func NewModule(registry *contracts.Contracts) *Module {
	return &Module{registry: registry}
}

func (m *Module) Name() string { return "order" }

func (m *Module) Init(ctx context.Context) error {
	repo := memory.New()
	m.svc = application.NewService(repo, m.registry)
	m.handlers = orderhttp.NewHandlers(m.svc)
	return nil
}

// RegisterContracts is a no-op: the order domain publishes no contract of its own
// (nothing else calls into it) - it only consumes the catalog contract.
func (m *Module) RegisterContracts(ctx context.Context, c *contracts.Contracts) error {
	return nil
}

func (m *Module) RegisterHTTP(ctx context.Context, mux *http.ServeMux) error {
	m.handlers.Register(mux)
	return nil
}

The *contracts.Contracts pointer satisfies the contracts.Modules interface, so handing it to NewService is where concrete becomes abstract - exactly once, at the boundary between wiring and business logic.

Grows later: this order/module.go reaches its final shape over stages 11–12 - Postgres pools replace the in-memory repo (stage 11) and a Kafka publisher plus a RegisterWorkers method are added (stage 12). Each changes only NewModule's signature and Init's body; the domain and application layers stay put. Likewise main.go is replaced by the server/worker/migrate subcommands in stage 13.

3. In main.go, pass the registry to the order module:

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

(The catalog still takes no registry; it publishes a contract but consumes none.)

Checkpoint

Shell
go build ./... && go run .

Add a product and capture its generated id, then place an order with only IDs and quantities:

Shell
PID=$(curl -s -X POST http://localhost:8080/v1/products \
  -d '{"name":"Widget","price_cents":500,"stock":10}' \
  | sed -n 's/.*"id":"\([a-f0-9]*\)".*/\1/p')

curl -s -X POST http://localhost:8080/v1/orders \
  -d "{\"user_id\":\"u_1\",\"items\":[{\"product_id\":\"$PID\",\"quantity\":2}]}"

The response's unit_price_cents and total_cents come from the catalog, not the request - the client cannot set them. Now name a product that doesn't exist:

Shell
curl -s -i -X POST http://localhost:8080/v1/orders \
  -d '{"user_id":"u_1","items":[{"product_id":"ghost","quantity":1}]}' | head -1
# → HTTP/1.1 400 Bad Request

What you learned

  • Consumers depend on the Modules interface; only main.go builds the concrete registry.
  • The registry pointer is injected empty at construction and read at request time, after Verify.
  • The same domain error becomes different HTTP statuses at different edges - the edge decides meaning.

Exercise

Why can a missing product be 404 at the catalog edge but 400 at the order edge?

Answer

At the catalog edge, the caller asked for a product resource that does not exist. At the order edge, the caller submitted an invalid order request that references an unknown product. The sentinel is the same fact; the edge decides the HTTP meaning.