MiniCart
Stage 08 A Second Domain

Stage 08 - A Second Domain

Build the order domain end to end, reusing every shape from the catalog.

Prereqs: stage 07 complete · Time: ~45 min

By the end: POST /v1/orders and GET /v1/orders/{id} work - and a client can send any price it likes.


The order entity

Goal: model an order as a historical record with snapshotted line items.

Learn

An order line stores a ProductID, but also copies the product's Name and UnitPriceCents into itself. That looks redundant - the price is in the catalog. But the catalog's price will change, and a placed order must not change with it. An order records what the deal was. So Item snapshots the name and price at order time.

Like Product, Order is valid by construction: NewOrder is the only way to make one. It validates the lines, computes each line total and the order total in integer cents, and stamps CreatedAt.

Build

1. Create order/domain/order.go:

Go
package domain

import "time"

// Item is one line of an order. Name and UnitPriceCents are a snapshot captured
// when the order is placed, so later catalog price or name changes never rewrite
// past orders.
type Item struct {
	ProductID      string
	Name           string
	UnitPriceCents int64
	Quantity       int
	LineTotalCents int64
}

// Order is a placed order. Like Product, it is valid by construction: NewOrder is
// the only way to make one.
type Order struct {
	ID         string
	UserID     string
	Items      []Item
	TotalCents int64
	CreatedAt  time.Time
}

// NewOrder validates the lines, computes each LineTotalCents and the order
// TotalCents, and stamps CreatedAt. Callers pass items whose UnitPriceCents were
// already resolved from an authoritative source (the catalog) - never the client.
func NewOrder(id, userID string, items []Item) (Order, error) {
	if len(items) == 0 {
		return Order{}, ErrNoItems
	}
	var total int64
	for i := range items {
		if items[i].Quantity <= 0 {
			return Order{}, ErrInvalidQuantity
		}
		items[i].LineTotalCents = items[i].UnitPriceCents * int64(items[i].Quantity)
		total += items[i].LineTotalCents
	}
	return Order{
		ID:         id,
		UserID:     userID,
		Items:      items,
		TotalCents: total,
		CreatedAt:  time.Now().UTC(),
	}, nil
}

NewOrder's doc comment already describes where this domain is headed: prices "resolved from an authoritative source (the catalog) - never the client." In this stage the order service still feeds it prices from the request; stage 10 makes the comment literally true. The domain code does not change - only the wiring around it.

2. Create order/domain/errors.go:

Go
package domain

import "errors"

var (
	ErrOrderNotFound   = errors.New("order not found")
	ErrNoItems         = errors.New("order must contain at least one item")
	ErrInvalidQuantity = errors.New("item quantity must be greater than zero")
)
Go note: package domain again - the same short name as the catalog's. Package names only need to be unique within an import path, and minimart/order/domain differs from minimart/catalog/domain. Every domain repeats the same four-layer naming; uniformity is the point.

Verify

Shell
go build ./order/...

Builds. No behavior yet - the port, adapter, and use cases come next.

Exercise

What breaks if Item stored only ProductID and totals were computed by looking up prices at read time?

Answer

Raise the catalog price and every past receipt silently inflates; delete a product and old orders can't render a name; any refund argues against a moving target. Financial records are frozen agreements. Snapshot them.


Port and adapter

Goal: define the order repository port and an in-memory adapter.

Learn

Same drill as the catalog: the domain owns a Repository interface (the port), and infrastructure/memory implements it. Orders need only Save and GetByID - no List, because no use case lists orders. A port declares exactly what the domain needs, no more.

Build

1. Create order/domain/repository.go:

Go
package domain

import "context"

// Repository is the persistence port for orders.
type Repository interface {
	Save(ctx context.Context, o Order) error
	GetByID(ctx context.Context, id string) (Order, error)
}

2. Create order/infrastructure/memory/repository.go:

Go
package memory

import (
	"context"
	"sync"

	"minimart/order/domain"
)

type Repository struct {
	mu     sync.RWMutex
	orders map[string]domain.Order
}

var _ domain.Repository = (*Repository)(nil)

func New() *Repository {
	return &Repository{orders: make(map[string]domain.Order)}
}

func (r *Repository) Save(ctx context.Context, o domain.Order) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.orders[o.ID] = o
	return nil
}

func (r *Repository) GetByID(ctx context.Context, id string) (domain.Order, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	o, ok := r.orders[id]
	if !ok {
		return domain.Order{}, domain.ErrOrderNotFound
	}
	return o, nil
}

The compile-time assertion var _ domain.Repository = (*Repository)(nil) fails the build if a method drifts.

In production: this slot holds a Postgres adapter instead - sqlc-generated reader/ and writer/ packages under order/infrastructure/postgres/. The port is unchanged; only the adapter behind it gets heavier. You do that swap for both catalog and order in stage 11.

Verify

Shell
go build ./order/...

Builds.

Exercise

Why does the order repository port omit List, even though the catalog repository has one?

Answer

A port should describe what the domain needs now, not what a database could do. Orders have no list use case yet, so adding List would make every adapter carry a method nobody calls.


Place order

Goal: write the PlaceOrder use case - with a known limitation.

Learn

PlaceOrder takes a request, builds domain.Items, calls NewOrder, and saves. The question is where each item's price comes from.

The order domain cannot reach the catalog yet - cross-domain calls are stages 09–10. So at this stage the request carries the price, and the service trusts it. State that plainly: the order domain uses a price it did not compute. That is a coupling and trust-boundary problem - the caller can send any number. Stage 10 removes it by fetching each price from the catalog. The final PlaceOrder we are heading toward resolves prices through the catalog contract; this intermediate one does not.

Build

1. order/application/service.go:

Go
package application

import "minimart/order/domain"

// Service is the order application layer. For now it depends only on the order
// repository port. In stage 10 it gains a second dependency - the catalog contract -
// so it can resolve real prices instead of trusting the caller.
type Service struct {
	repo domain.Repository
}

func NewService(repo domain.Repository) *Service {
	return &Service{repo: repo}
}

2. order/application/id.go:

Go
package application

import (
	"crypto/rand"
	"encoding/hex"
)

func newID() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}

3. order/application/place_order.go:

Go
package application

import (
	"context"

	"minimart/order/domain"
)

// PlaceOrderRequest is the input to PlaceOrder. At this stage each item carries a
// Name and UnitPriceCents sent by the caller. The order domain has no way to reach
// the catalog yet, so it trusts these values - a price it did not compute. Stage 10
// removes both fields and fetches the price from the catalog instead.
type PlaceOrderRequest struct {
	UserID string
	Items  []PlaceOrderItem
}

type PlaceOrderItem struct {
	ProductID      string
	Name           string
	UnitPriceCents int64
	Quantity       int
}

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
		}
		items = append(items, domain.Item{
			ProductID:      it.ProductID,
			Name:           it.Name,
			UnitPriceCents: it.UnitPriceCents,
			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
}

4. order/application/get_order.go:

Go
package application

import (
	"context"

	"minimart/order/domain"
)

func (s *Service) GetOrder(ctx context.Context, id string) (domain.Order, error) {
	return s.repo.GetByID(ctx, id)
}

Verify

Shell
go build ./order/...

Builds. PlaceOrderItem carries Name and UnitPriceCents from the caller - the fields stage 10 removes once prices come from the catalog.

Exercise

Why call out the client-supplied price as a limitation instead of hiding it until the contract stages?

Answer

Because architecture is partly about noticing trust boundaries. This temporary shape works mechanically, but it lets the caller choose a price the catalog should own. Naming the flaw makes the contract in stages 09-10 feel necessary, not decorative.


Order HTTP

Goal: add the HTTP edge for orders.

Learn

Same edge pattern as the catalog: a Handlers struct holding the service, a response.go with the JSON helpers and the error translator, DTOs with JSON tags, and a Register that maps routes. The request DTO mirrors the current use case - it still carries name and unit_price_cents. Stage 10 strips both.

Build

1. order/presentation/http/dto.go:

Go
package http

import (
	"time"

	"minimart/order/domain"
)

// placeOrderRequest is the body of POST /v1/orders. At this stage each item carries
// a name and unit price from the client. Stage 10 strips both fields: the client
// will name products and quantities only, and prices come from the catalog.
type placeOrderRequest struct {
	UserID string           `json:"user_id"`
	Items  []placeOrderItem `json:"items"`
}

type placeOrderItem struct {
	ProductID      string `json:"product_id"`
	Name           string `json:"name"`
	UnitPriceCents int64  `json:"unit_price_cents"`
	Quantity       int    `json:"quantity"`
}

type orderResponse struct {
	ID         string         `json:"id"`
	UserID     string         `json:"user_id"`
	Items      []itemResponse `json:"items"`
	TotalCents int64          `json:"total_cents"`
	CreatedAt  string         `json:"created_at"`
}

type itemResponse struct {
	ProductID      string `json:"product_id"`
	Name           string `json:"name"`
	UnitPriceCents int64  `json:"unit_price_cents"`
	Quantity       int    `json:"quantity"`
	LineTotalCents int64  `json:"line_total_cents"`
}

func toOrderResponse(o domain.Order) orderResponse {
	items := make([]itemResponse, 0, len(o.Items))
	for _, it := range o.Items {
		items = append(items, itemResponse{
			ProductID:      it.ProductID,
			Name:           it.Name,
			UnitPriceCents: it.UnitPriceCents,
			Quantity:       it.Quantity,
			LineTotalCents: it.LineTotalCents,
		})
	}
	return orderResponse{
		ID:         o.ID,
		UserID:     o.UserID,
		Items:      items,
		TotalCents: o.TotalCents,
		CreatedAt:  o.CreatedAt.Format(time.RFC3339),
	}
}

2. order/presentation/http/response.go:

Go
package http

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

	"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 to HTTP status codes. Stage 10 adds
// one more case - the catalog contract's ErrProductNotFound - once the order
// service starts resolving products through the catalog.
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):
		writeJSON(w, http.StatusBadRequest, errorResponse{err.Error()})
	default:
		writeJSON(w, http.StatusInternalServerError, errorResponse{"internal error"})
	}
}

3. order/presentation/http/place_order.go:

Go
package http

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

	"minimart/order/application"
)

type Handlers struct {
	svc *application.Service
}

func NewHandlers(svc *application.Service) *Handlers {
	return &Handlers{svc: svc}
}

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,
			Name:           it.Name,
			UnitPriceCents: it.UnitPriceCents,
			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))
}

4. order/presentation/http/get_order.go:

Go
package http

import "net/http"

func (h *Handlers) GetOrder(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	o, err := h.svc.GetOrder(r.Context(), id)
	if err != nil {
		respondDomainError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, toOrderResponse(o))
}

5. order/presentation/http/routes.go:

Go
package http

import "net/http"

func (h *Handlers) Register(mux *http.ServeMux) {
	mux.HandleFunc("POST /v1/orders", h.PlaceOrder)
	mux.HandleFunc("GET /v1/orders/{id}", h.GetOrder)
}

respondDomainError maps ErrOrderNotFound → 404, the two validation errors → 400, and everything else → 500 with a generic mask. Stage 10 adds one case: the catalog contract's ErrProductNotFound → 400.

Verify

Shell
go build ./...

Builds. The routes aren't reachable until the module is wired - next section.

Exercise

Why does this request DTO still include name and unit_price_cents?

Answer

The DTO mirrors the use case as it exists today. At this point the order domain has no safe way to ask the catalog for product data, so the edge accepts temporary input that stage 10 removes after the contract is available.


Wire and observe

Goal: register the order module and see the trust gap first-hand.

Build

1. Create order/module.go:

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"
)

type Module struct {
	svc      *application.Service
	handlers *orderhttp.Handlers
}

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

func NewModule() *Module { return &Module{} }

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

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

// RegisterContracts is a no-op: the order domain publishes no contract of its own.
// In stage 10 the order module starts *consuming* the catalog contract - but it
// still registers none.
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
}

Stage 10 changes NewModule() to take the shared registry, so the service can reach the catalog. For now it takes nothing.

2. In main.go, add the order module to the slice:

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

and add "minimart/order" to the imports. That is the whole cost of a new domain at the composition root: one line and an import.

Checkpoint

Shell
go run .

Place an order with any product_id and a price you choose. At this stage the order service cannot validate the product against the catalog:

Shell
curl -s -X POST http://localhost:8080/v1/orders \
  -d '{"user_id":"u_1","items":[{"product_id":"p_1","name":"Widget","unit_price_cents":1,"quantity":100}]}'
JSON
{"id":"...","user_id":"u_1","items":[{"product_id":"p_1","name":"Widget","unit_price_cents":1,"quantity":100,"line_total_cents":100}],"total_cents":100,"created_at":"..."}

(Your id and created_at differ - they're generated.)

The math is exact: 100 units at 1 cent is 100 cents. The price is the problem. The order domain has no way to ask the catalog whether p_1 exists or what it costs, so it accepts both the product ID and the price in the request. A client can order a hundred bags of beans for one dollar - the order domain trusts a price it did not compute.

The obvious shortcut - import "minimart/catalog/application" from the order code - is the wrong fix, and stage 09 explains why it is forbidden. Stage 09 publishes a contract; stage 10 has the order service consume it, so prices come from the catalog and the caller can no longer set them.

What you learned

  • A second domain is the same drill: domain → port → adapter → use cases → edge → module.
  • Package short names repeat across domains; import paths keep them distinct.
  • Trusting the client for prices is a coupling the next two stages remove.

Exercise

What would be wrong with fixing the price problem by importing catalog/application from the order service?

Answer

It would make the order domain depend on catalog internals: the service type, application DTOs, and whatever dependencies that package grows later. A published contract gives order the one capability it needs without letting catalog's private shape leak across the boundary.