MiniCart
Stage 05 The Front Door

Stage 05 - The Front Door

Put an HTTP input adapter in front of the service - request DTOs, handlers, and a main.go that wires it all and serves the three catalog routes.

Prereqs: stage 04 complete · Time: ~25 min

By the end: curl can create, list, and fetch products over HTTP.


Input adapters & DTOs

Goal: define the HTTP wire types, kept separate from the domain entity.

Learn

The presentation layer holds input adapters - HTTP handlers now, Kafka consumers later. An input adapter translates between an outside protocol (JSON on the wire) and the application layer. It carries no business rules.

Its wire types are DTOs (data transfer objects), and they are deliberately not the domain entity:

  • createProductRequest - the JSON body the API accepts.
  • productResponse - the JSON the API returns.

Keeping these separate from domain.Product means the public wire contract stays stable even if the entity changes shape, and the entity stays free of json tags (recall the Entities section). The json:"price_cents" tags live here, at the edge, where wire format belongs.

This is intentional duplication, not waste. DRY is about one decision having one home; it does not mean every similar struct must collapse into one type. The entity, the HTTP response, and later the cross-domain contract have different audiences and different reasons to change.

Build

Create catalog/presentation/http/dto.go:

Go
// Package http is the catalog's input adapter for HTTP. It translates between the
// wire (JSON) and the application layer, and never contains business rules.
package http

import "minimart/catalog/domain"

// createProductRequest is the JSON body accepted by POST /v1/products.
type createProductRequest struct {
	Name       string `json:"name"`
	PriceCents int64  `json:"price_cents"`
	Stock      int    `json:"stock"`
}

// productResponse is the JSON returned to clients. It is kept separate from the
// domain entity so the wire contract stays stable even if the entity changes.
type productResponse struct {
	ID         string `json:"id"`
	Name       string `json:"name"`
	PriceCents int64  `json:"price_cents"`
	Stock      int    `json:"stock"`
}

func toProductResponse(p domain.Product) productResponse {
	return productResponse{
		ID:         p.ID,
		Name:       p.Name,
		PriceCents: p.PriceCents,
		Stock:      p.Stock,
	}
}

toProductResponse is the one-way map from entity to wire type. There is no reverse map: the handler reads the request DTO's fields into the application DTO by hand (the Handlers section), so the two shapes can diverge without a struct-copy trick hiding it.

Verify

Shell
go build ./catalog/...

Prints nothing, exits 0. The request and response types are unused so far - Go allows unused package-level types; it rejects only unused imports and locals.

Exercise

productResponse is field-for-field identical to domain.Product today. Why define it at all instead of encoding the entity directly?

Answer

To decouple the wire contract from the entity. The day the entity gains an internal field (CostCents, SupplierID, an audit stamp) you do not want it silently serialized to every client, and the day a client needs a renamed or computed field you do not want to touch the domain. Identical today, independent forever.


Handlers

Goal: write the three handlers - decode, call a use case, encode.

Learn

A handler is a method on Handlers that does three things: decode the request DTO, call one use case, encode the response DTO. No business logic - that lives in the domain and application layers below it. Two small helpers, writeJSON and errorResponse, keep response writing in one place.

Error handling here is a stopgap. Each handler writes a single hard-coded status on failure. That is honest enough to run, but wrong in detail: a not-found and a validation failure should not share a status. Stage 06 replaces every stopgap branch with respondDomainError, one function that maps each domain error to the right code.

Build

1. Create catalog/presentation/http/response.go with the two helpers:

Go
package http

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

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"`
}
This file grows in stage 06: respondDomainError joins these helpers, and the handler stopgaps below are rewired to call it.

2. Create catalog/presentation/http/create_product.go:

Go
package http

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

	"minimart/catalog/application"
)

// Handlers holds the catalog HTTP handlers and their one dependency: the service.
type Handlers struct {
	svc *application.Service
}

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

func (h *Handlers) CreateProduct(w http.ResponseWriter, r *http.Request) {
	var req createProductRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, errorResponse{"invalid JSON body"})
		return
	}
	p, err := h.svc.CreateProduct(r.Context(), application.CreateProductRequest{
		Name:       req.Name,
		PriceCents: req.PriceCents,
		Stock:      req.Stock,
	})
	if err != nil {
		// Stage 05 stopgap: any service error becomes a 400. Stage 06 replaces this
		// with respondDomainError, which sorts 400 from 404 from 500.
		writeJSON(w, http.StatusBadRequest, errorResponse{err.Error()})
		return
	}
	writeJSON(w, http.StatusCreated, toProductResponse(p))
}

The handler copies the request DTO into the application DTO field by field, then maps the returned entity out through toProductResponse. Domain types never reach the wire directly.

The field-by-field copy is deliberately boring. A generic struct copier would save a few keystrokes but hide the contract decision: which external fields are accepted, which internal fields are ignored, and which names belong to the API instead of the domain.

3. Create catalog/presentation/http/get_product.go:

Go
package http

import "net/http"

func (h *Handlers) GetProduct(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	p, err := h.svc.GetProduct(r.Context(), id)
	if err != nil {
		// Stage 05 stopgap: treat any error as not-found. Stage 06 distinguishes a
		// real 404 from a 500 with respondDomainError.
		writeJSON(w, http.StatusNotFound, errorResponse{err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, toProductResponse(p))
}

r.PathValue("id") reads the {id} segment from the route pattern (Go 1.22+).

4. Create catalog/presentation/http/list_products.go:

Go
package http

import "net/http"

func (h *Handlers) ListProducts(w http.ResponseWriter, r *http.Request) {
	ps, err := h.svc.ListProducts(r.Context())
	if err != nil {
		// Stage 05 stopgap: report a generic 500. Stage 06 routes this through
		// respondDomainError.
		writeJSON(w, http.StatusInternalServerError, errorResponse{"internal error"})
		return
	}
	out := make([]productResponse, 0, len(ps))
	for _, p := range ps {
		out = append(out, toProductResponse(p))
	}
	writeJSON(w, http.StatusOK, out)
}

make([]productResponse, 0, len(ps)) starts a non-nil, empty slice, so an empty catalog encodes as [], not null.

Verify

Shell
go build ./catalog/...

Prints nothing, exits 0. Nothing serves these handlers yet - that is the Wire it section.

Exercise

What kind of code would be a smell inside one of these handlers?

Answer

Any business decision: price validation, stock math, ID rules, or cross-domain lookups. A handler should decode the request, call one use case, translate the result, and stop.


Wire it

Goal: register the routes and wire the whole stack in main.go, then serve it.

Learn

main.go is the composition root - the one place allowed to import every layer, because something has to assemble them. It picks the concrete adapter, injects it up through the constructors, and registers the routes:

Code
memory.New() → application.NewService → NewHandlers → Register(mux)

That chain is the Dependency Rule paid off: swapping storage is one line here, and no layer below knows or cares. /health stays in the root - it answers an operational question, not a business one.

This main.go is deliberately hand-wired and small. Stage 07 rewrites it into the module pattern, where each domain wires itself and main just boots the modules. For now, explicit wiring makes the whole graph visible on one screen.

Build

1. Create catalog/presentation/http/routes.go:

Go
package http

import "net/http"

// Register wires the catalog routes onto the shared mux. Method-aware patterns
// (Go 1.22+) let the standard router dispatch without a framework.
func (h *Handlers) Register(mux *http.ServeMux) {
	mux.HandleFunc("POST /v1/products", h.CreateProduct)
	mux.HandleFunc("GET /v1/products", h.ListProducts)
	mux.HandleFunc("GET /v1/products/{id}", h.GetProduct)
}

2. Replace main.go with the wired version:

Go
package main

import (
	"log"
	"net/http"

	"minimart/catalog/application"
	"minimart/catalog/infrastructure/memory"
	cataloghttp "minimart/catalog/presentation/http"
)

func main() {
	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"}`))
	})

	// Composition root: choose the concrete adapter, inject it up the layers, and
	// register the routes. Stage 07 replaces this hand-wiring with the module pattern.
	repo := memory.New()
	svc := application.NewService(repo)
	handlers := cataloghttp.NewHandlers(svc)
	handlers.Register(mux)

	log.Println("minimart listening on http://localhost:8080")
	if err := http.ListenAndServe(":8080", mux); err != nil {
		log.Fatal(err)
	}
}
Go note: cataloghttp is an import alias - the package is named http, which would collide with the standard net/http, so the local name disambiguates.

Checkpoint

Run the server:

Shell
go run .
# minimart listening on http://localhost:8080

In a second terminal, create then read back a product:

Shell
curl -s -X POST localhost:8080/v1/products \
  -d '{"name":"Widget","price_cents":500,"stock":3}'
# {"id":"<hex>","name":"Widget","price_cents":500,"stock":3}

curl -s localhost:8080/v1/products
# [{"id":"<hex>","name":"Widget","price_cents":500,"stock":3}]

curl -s localhost:8080/v1/products/<hex>   # paste the id from above
# {"id":"<hex>","name":"Widget","price_cents":500,"stock":3}

The store is in memory, so the product is gone when you restart the server.

What you learned

  • The presentation layer is an input adapter: decode, call a use case, encode.
  • Wire DTOs stay separate from the entity to keep the contract stable.
  • main.go is the composition root; it injects the adapter and owns /health.

Next: the stopgap error branches become one honest translation layer.

Exercise

Why is main.go allowed to import packages from every layer?

Answer

It is the composition root. Its job is to choose concrete adapters and connect them to the use cases. That exception should stay narrow: once the graph is assembled, business behavior still flows through the ports and use cases.