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:
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:
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 domainagain - the same short name as the catalog's. Package names only need to be unique within an import path, andminimart/order/domaindiffers fromminimart/catalog/domain. Every domain repeats the same four-layer naming; uniformity is the point.
Verify
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:
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:
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-generatedreader/andwriter/packages underorder/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
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:
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:
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:
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:
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
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:
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:
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:
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:
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:
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
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:
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:
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
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:
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}]}'{"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.