Dev Skills

Step 7 — Orders, and getting it ready to deploy

The last endpoints, then logging, limits, a container and the hardening pass.

Topic 81 of 98 · 5 min to read · 50 min to build
After this topic you can
  • Build order endpoints that are scoped to the user, paged and joined.
  • Add structured logging and rate limits to the shop.
  • Containerise the service and run the hardening checks before shipping.

Orders: scoped, paged, and joined

Nothing new here. It is the same three rules from earlier projects, applied together.

internal/adapter/storage/postgres/order.go
func (r *Repository) ListByUser(ctx context.Context, userID int64, limit, offset int) ([]Order, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT o.id, o.total_cents, o.status, o.created_at,
count(oi.id) AS line_count
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.user_id = $1 -- scoped, always
GROUP BY o.id
ORDER BY o.created_at DESC
LIMIT $2 OFFSET $3 -- paged, always
`, userID, limit, offset)
// ... scan rows exactly as in step 5
}
The LEFT JOIN with count is what stops this being N+1. Without it you would loop and query per order.

Structured logging

cmd/api/main.go
var h slog.Handler
if cfg.Env == "production" {
h = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
} else {
h = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
}
slog.SetDefault(slog.New(h))
slog.Info("order placed",
"order_id", o.ID, "user_id", userID,
"total_cents", o.TotalCents, "items", len(items))
JSON in production because machines read it, text in development because you do. Never log a password, a token, or a whole request body.

Rate limits where they matter

cmd/api/main.go
authLimit := middleware.RateLimit(0.2, 5) // ~12 a minute, burst 5
apiLimit := middleware.RateLimit(20, 40) // generous for normal use
mux.Handle("POST /v1/auth/login", authLimit(http.HandlerFunc(userH.Login)))
mux.Handle("POST /v1/auth/register", authLimit(http.HandlerFunc(userH.Register)))
mux.Handle("POST /v1/checkout", apiLimit(protect(http.HandlerFunc(orderH.Checkout))))
Login and register get the tight limit, because that is where password guessing and mass sign-ups land.

Every command to containerise it

terminal
# 1. the Dockerfile
cat > Dockerfile <<'EOF'
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /api ./cmd/api
FROM gcr.io/distroless/static-debian12
COPY --from=build /api /api
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/api"]
EOF
# 2. build and check the size
docker build -t shop:latest .
docker images shop:latest
# 3. run the whole stack
docker compose up -d
docker compose logs -f api
# 4. useful while developing
docker compose down # stop
docker compose down -v # stop and wipe the database
docker compose exec db psql -U postgres -d shop

The two container settings people miss

docker-compose.yml — the api service environment
environment:
DATABASE_URL: postgres://postgres:secret@db:5432/shop?sslmode=disable
JWT_SECRET: dev-secret-at-least-32-characters-long
UPLOAD_DIR: /data/uploads
GOMEMLIMIT: 450MiB # ~90% of the memory limit
GOMAXPROCS: "2" # match the CPU limit, not the host
volumes:
- uploads:/data/uploads # uploads must outlive the container
Without GOMEMLIMIT the garbage collector does not know the container’s memory limit. By default it lets the heap grow to about twice the live data before collecting, which can cross the limit and get the process OOM-killed. Before Go 1.25, GOMAXPROCS defaulted to the host’s core count rather than the container’s CPU limit. Go 1.25 reads the limit itself, but this Dockerfile builds with 1.22.

The hardening pass

  • Every route’s auth — grep your Routes functions for a handler registered without protect, and every admin route without adminOnly.
  • Every query’s scope — WHERE id = $1 with no AND user_id = $2 on anything user-owned.
  • Every query’s SQL — any fmt.Sprintf or + near a query string that touches a request value.
  • Every file path — anything built from something the client sent.
  • Every log line — a password, a token, or a whole body.
  • Every goroutine — one with no way to exit.

The four checks, then ship

terminal
gofmt -l . # must print nothing
go vet ./... # must be silent
go test -race ./... # must be green
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
# then a Makefile so you stop retyping
cat > Makefile <<'EOF'
run: ; go run ./cmd/api
test: ; go test -race -cover ./...
lint: ; go vet ./... && gofmt -l .
build: ; CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/api ./cmd/api
up: ; docker compose up -d
down: ; docker compose down
migrate: ; migrate -path migrations -database "$(DATABASE_URL)" up
check: ; gofmt -l . && go vet ./... && go test -race ./...
EOF
make check
Worth knowing: Three projects done. You have built a public service, a service with accounts and private data, and a service with roles, files and money that cannot be lost. That is a real portfolio, and every idea in the interview topics now has something of yours to point at.
The short version
  • Orders are the same three rules again: scoped, paged, joined rather than looped.
  • Structured logs with slog: JSON in production, and never log secrets.
  • Rate-limit login and register hardest. That is where attacks land.
  • GOMEMLIMIT and GOMAXPROCS in every container, and a volume for uploads.
Remember: Shipping is pagination, logging, limits, a container and one hardening pass. Each is small; together they are the difference between a demo and a service.
Build this before moving on
Finish orders, add logging and rate limits, containerise it, and work the hardening checklist line by line. Then run make check and get it green.
Then try these in your own editor
  1. 1Hammer login with a curl loop until you get 429, and check the Retry-After header.
  2. 2Restart the container and confirm uploaded images are still there. Then remove the volume and see them vanish.
  3. 3Set GOMEMLIMIT far too low and watch the garbage collector work harder in the logs.
  4. 4Find one thing to delete: a helper used once, or an interface with one implementation.
Official documentation
Check yourselfTake the Project 3 — Small shop test