Building a Complete Project End-to-End
The last three chapters built the order-management example in pieces: the
package layout and dependency direction (Chapter 6.39), the ports-and-adapters
vocabulary and a fast in-memory test double (Chapter 6.40), and the DDD-modeled
Order aggregate with its invariants and domain events (Chapter 6.41). This
chapter's job is narrower than any of those, and in some ways harder: wiring
every piece into one program that actually runs, and tracing one real request
through all of it, top to bottom.
Nothing here reteaches material from earlier chapters. Chapter 6.36 already
covers pgx connection pooling and query patterns in depth; Chapter 6.4
already covers net/http routing and handler patterns; Chapter 6.38 already
covers Docker; a later chapter, 6.43, covers deploying to Google Cloud. This
chapter's only new content is the integration between all of them — the part
that's easy to skip when each pattern is taught in isolation, and the part
that determines whether a project built from all of them actually holds
together under a real deadline.
The Directory Tree
ordermgmt/
cmd/
api/
main.go
internal/
domain/
order.go
money.go
events.go
repository.go
refund_service.go
usecase/
place_order.go
mark_paid.go
ship_order.go
cancel_order.go
adapter/
http/
handler.go
router.go
postgres/
order_repository.go
paymentgateway/
stub_refunder.go
webhook/
publisher.go
platform/
config.go
migrations/
0001_create_orders.sql
Dockerfile
go.mod
go.sum
domain holds everything from Chapter 6.41 with no imports beyond the
standard library. usecase depends only on domain. adapter/* depends on
domain and usecase, never the reverse. cmd/api/main.go is the only file
in the tree allowed to import all of them at once.
A Deliberate Stand-In: the Payment Gateway Adapter
Chapter 6.41's domain.Refunder port exists specifically to keep a real
payment gateway — Stripe, Adyen, or similar — out of the domain package,
since calling out to one is exactly the kind of dependency an aggregate must
never have. This project doesn't integrate a real gateway yet, and it
shouldn't pretend to: paymentgateway.StubRefunder satisfies
domain.Refunder with a fake confirmation ID and no network call at all,
making the stand-in explicit instead of quietly wiring a database adapter in
its place.
// internal/adapter/paymentgateway/stub_refunder.go
package paymentgateway
import (
"context"
"github.com/google/uuid"
)
// StubRefunder is a deliberate stand-in for a real payment gateway
// client. It satisfies domain.Refunder without ever calling out to an
// actual processor — swap it for a Stripe or Adyen client once this
// project needs to process real refunds.
type StubRefunder struct{}
func NewStubRefunder() *StubRefunder {
return &StubRefunder{}
}
func (r *StubRefunder) Refund(
ctx context.Context, orderID string,
) (string, error) {
return uuid.NewString(), nil
}
Nothing here talks to a payment processor — uuid.NewString() invents a
confirmation ID that looks like the real thing, which is enough to exercise
CancellationService.CancelShippedOrder end to end without a Stripe API
key, a sandbox account, or a network dependency in the test/dev loop. The
package name (paymentgateway, not postgres) says what it abstracts;
the type name (StubRefunder, not Refunder) says it isn't production-
ready yet — both are intentional, so nobody mistakes this for the real
integration by reading the wiring in main.go alone.
Wiring: main.go
// cmd/api/main.go
package main
import (
"context"
"log"
"net/http"
"os"
"github.com/jackc/pgx/v5/pgxpool"
adapterhttp "ordermgmt/internal/adapter/http"
"ordermgmt/internal/adapter/paymentgateway"
"ordermgmt/internal/adapter/postgres"
"ordermgmt/internal/adapter/webhook"
"ordermgmt/internal/domain"
"ordermgmt/internal/usecase"
)
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("connect to postgres: %v", err)
}
defer pool.Close()
orders := postgres.NewOrderRepository(pool)
refunder := paymentgateway.NewStubRefunder()
publisher := webhook.NewPublisher(os.Getenv("WEBHOOK_ENDPOINT"))
cancelSvc := &domain.CancellationService{Refunder: refunder}
placeOrder := &usecase.PlaceOrder{Orders: orders}
markPaid := &usecase.MarkPaid{Orders: orders}
shipOrder := &usecase.ShipOrder{Orders: orders, Publisher: publisher}
cancelOrder := &usecase.CancelOrder{
Orders: orders, Cancellation: cancelSvc, Publisher: publisher,
}
handler := &adapterhttp.OrderHandler{
PlaceOrder: placeOrder,
MarkPaid: markPaid,
ShipOrder: shipOrder,
CancelOrder: cancelOrder,
}
mux := adapterhttp.NewRouter(handler)
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Every dependency arrow here points the way the last three chapters described:
main.go builds concrete adapters (postgres.OrderRepository,
webhook.Publisher) and hands them to use cases as interface values. Nothing
downstream of this function ever constructs its own dependencies — they all
arrive through a constructor or a struct literal, which is the whole of Go's
answer to dependency injection.
Tracing One Request: Shipping an Order
Follow PUT /orders/{id}/ship from the wire to Postgres and back. This is
the same Ship rule from Chapters 6.39–6.41, now running for real.
1. HTTP adapter receives the request and calls the use case.
// internal/adapter/http/handler.go
func (h *OrderHandler) Ship(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := h.ShipOrder.Execute(r.Context(), id); err != nil {
writeDomainError(w, err)
return
}
w.WriteHeader(http.StatusOK)
}
2. The use case loads the aggregate, delegates to it, saves, and publishes.
// internal/usecase/ship_order.go
func (uc *ShipOrder) Execute(ctx context.Context, orderID string) error {
order, err := uc.Orders.FindByID(ctx, orderID)
if err != nil {
return err
}
if err := order.Ship(); err != nil {
return err
}
if err := uc.Orders.Save(ctx, order); err != nil {
return err
}
for _, event := range order.PullEvents() {
if err := uc.Publisher.Publish(ctx, event); err != nil {
return err
}
}
return nil
}
3. The domain aggregate enforces the invariant. This is the exact Ship
method from Chapter 6.41 — it either flips pending/paid state to
shipped and records an OrderShipped event, or refuses with
ErrOrderNotPaid if the order was never paid. No layer above this one
contains that rule; every layer above it just reacts to whether it
succeeded.
4. The repository adapter persists the new state.
// internal/adapter/postgres/order_repository.go
func (r *OrderRepository) Save(ctx context.Context, o *domain.Order) error {
_, err := r.pool.Exec(ctx,
`UPDATE orders SET status = $2 WHERE id = $1`,
o.ID(), string(o.Status()))
return err
}
Chapter 6.36 covers connection pooling, prepared statements, and transaction
patterns for pgx — this adapter is a direct application of that material,
with no new query technique introduced here.
Step 2 above calls uc.Orders.FindByID before any of this — the read side
of the same adapter, and the piece Chapter 6.41 could only declare as a
port, since reconstructing an Order from two database tables requires
bypassing domain.NewOrder's invariants entirely:
// internal/adapter/postgres/order_repository.go (continued)
func (r *OrderRepository) FindByID(
ctx context.Context, id string,
) (*domain.Order, error) {
var customerID, status string
row := r.pool.QueryRow(ctx,
`SELECT customer_id, status FROM orders WHERE id = $1`, id)
if err := row.Scan(&customerID, &status); err != nil {
return nil, err
}
rows, err := r.pool.Query(ctx,
`SELECT product_id, quantity, unit_price, currency
FROM order_lines WHERE order_id = $1`, id)
if err != nil {
return nil, err
}
defer rows.Close()
var lines []domain.OrderLine
for rows.Next() {
var productID, currency string
var qty int
var cents int64
if err := rows.Scan(&productID, &qty, ¢s, ¤cy); err != nil {
return nil, err
}
price, err := domain.NewMoney(cents, currency)
if err != nil {
return nil, err
}
lines = append(lines, domain.OrderLine{
ProductID: productID, Quantity: qty, UnitPrice: price,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return domain.Rehydrate(
id, customerID, domain.OrderStatus(status), lines, nil,
), nil
}
The orders row supplies customerID and status; the order_lines rows
are reassembled into domain.Money via domain.NewMoney(cents, currency)
one row at a time, exactly the translation the migration's schema comment
above describes. The final argument to Rehydrate is nil rather than a
list of events on purpose: a use case always calls order.PullEvents() and
publishes the result immediately after a successful Save (step 2 above),
so by the time any later FindByID reloads that same row, its pending
events have already been drained and published — there are never any left
to restore.
5. The webhook adapter turns the domain event into an outbound call, using the retry and delivery patterns from Chapter 6.23:
// internal/adapter/webhook/publisher.go
func (p *Publisher) Publish(ctx context.Context, e domain.DomainEvent) error {
body, err := json.Marshal(e)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(
ctx, http.MethodPost, p.endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
6. The response returns to the client as a 200 OK, or a mapped error
status if order.Ship() refused. The full round trip: HTTP handler to use
case to domain aggregate to repository to Postgres, and back out through a
webhook publish — six hops, and the business rule itself lives in exactly
one of them.
From the outside, that entire round trip looks like one plain HTTP call:
PUT /orders/order-42/ship HTTP/1.1
Host: api.ordermgmt.example.com
HTTP/1.1 409 Conflict
Content-Type: application/json
{"error": "order: not in paid status"}
Everything this chapter traced through six hops is invisible from here — the client only ever sees a status code and a JSON body, exactly as it should.
The Other Common Path: Placing an Order
POST /orders follows the same shape with one difference worth calling out:
PlaceOrder calls domain.NewOrder followed by one order.AddLine per item
in the request body, so the same invariant from Chapter 6.41 — no line items
once an order leaves pending — is enforced during creation too, not only
during later transitions. The repository's NextID (declared on
domain.OrderRepository in Chapter 6.41) generates the identifier before the
first Save, keeping ID generation a persistence concern rather than
something the aggregate invents for itself.
Migrations
migrations/0001_create_orders.sql defines the table the Postgres adapter
reads and writes — schema management tooling is a deployment concern, so a
plain, numbered SQL file (applied with a migration tool of your choice) is
enough:
CREATE TABLE orders (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
);
CREATE TABLE order_lines (
order_id TEXT NOT NULL REFERENCES orders(id),
product_id TEXT NOT NULL,
quantity INT NOT NULL,
unit_price BIGINT NOT NULL,
currency TEXT NOT NULL
);
Note the shape mirrors the domain model from Chapter 6.41 (Order and its
OrderLines) but is not identical to it — unit_price is a plain BIGINT
of cents here, reassembled into a domain.Money value object only inside
the Postgres adapter. The database schema is allowed to look like a
database; only the adapter needs to know how to translate it.
Integration is not "does it compile" — it's "does the one rule that matters still enforce itself no matter which of the six layers a request happens to be passing through."
Containers and Deployment, Briefly
Chapter 6.38 covers writing a production-quality Dockerfile for a Go
service — multi-stage builds, minimal base images, non-root users — in full;
this project uses exactly that pattern with no changes. For day-to-day
development, a docker-compose.yml running only Postgres (not the Go
service itself) alongside go run ./cmd/api on the host gives the fastest
edit-run loop, since rebuilding a container image on every change is rarely
worth it before a deploy; reserve the full multi-stage build for CI and the
deployed artifact:
# Dockerfile
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/api ./cmd/api
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/api /api
ENTRYPOINT ["/api"]
Chapter 6.43 covers deploying a container like this one to Google Cloud (Cloud
Run, GKE, or Compute Engine, with their tradeoffs) in full; nothing about
this particular service changes that guidance — it's a stateless container
reading DATABASE_URL and WEBHOOK_ENDPOINT from the environment, which is
exactly the shape those deployment targets expect.
os.Getenv("K_SERVICE"), somewhere inside internal/adapter or worse internal/domain. Keep platform detection and configuration parsing entirely inside cmd/api/main.go and internal/platform — the moment a deployment target's name appears inside internal/domain, Chapter 6.39's inward-pointing dependency rule has been violated by the newest, least obvious ring of all: the deployment platform itself.Frequently Asked Questions
Why does main.go construct postgres.NewOrderRepository and webhook.NewPublisher directly instead of going through some kind of container?
Because at four use cases and four adapters, a single readable function is the cheapest possible way to wire dependencies together, and adding a DI container here would be solving a problem this project doesn't have yet. The deep dive above is explicit about when that changes: reach for something like google/wire once wiring outgrows one screen, or once the same adapters need assembling multiple ways — a test binary, a worker binary, and the API binary all sharing the same postgres.OrderRepository, say.
In the ship-order trace, which of the six hops actually contains the business rule?
Exactly one: step 3, inside order.Ship() itself. Every other hop — the HTTP handler, the use case, the Postgres adapter, the webhook publisher — only reacts to whether that one call succeeded or returned ErrOrderNotPaid. That's the whole payoff this chapter is tracing: a request can pass through six layers of translation without the rule itself ever being duplicated or bypassable from any of them.
The orders table stores unit_price as a plain BIGINT, but the domain uses a Money value object — is that a mismatch bug?
No, it's intentional, and the chapter calls it out directly: the database schema is allowed to look like a database, storing cents as an integer, while only the Postgres adapter knows how to reassemble that integer and a currency column back into a domain.Money value. Keeping that translation confined to one adapter is what lets the schema evolve (an index, a new column) without ever touching domain.Money itself.
Why does the chapter warn against checking os.Getenv("K_SERVICE") inside internal/adapter or internal/domain?
Because that one line would quietly reintroduce the exact violation Chapter 6.39 spent its whole opening on: an inner layer importing knowledge about an outer one, just with a cloud platform's name standing in for a database driver this time. Platform detection and environment parsing belong entirely inside cmd/api/main.go and internal/platform — the moment a deployment target's name leaks into internal/domain, the dependency arrow has started pointing the wrong way again.
Why does FindByID pass nil for the events argument to domain.Rehydrate instead of loading whatever events were last recorded?
Because there's nothing to load — a use case always calls order.PullEvents() and hands each event to the publisher immediately after a successful Save, so by the time a later FindByID call reloads that same row, its events have already been drained and published. Passing nil isn't a shortcut that drops data; it reflects that pending domain events are meant to be transient, published once and gone, not a durable column this query needs to fetch.
Why does paymentgateway.StubRefunder exist instead of just wiring a real Stripe or Adyen client in main.go?
Because this project never claimed to process real refunds, and pretending otherwise by wiring a half-finished real gateway client would be worse than being explicit about the gap. StubRefunder satisfies domain.Refunder completely — CancellationService.CancelShippedOrder can't tell the difference at compile time — while making it obvious to anyone reading main.go that swapping in a real processor is still a to-do, not an already-solved problem quietly wearing a postgres name.
This chapter mentions auth, rate limiting, and real webhook retries are all missing — why ship something so incomplete? Because the point of the four-chapter arc was never to hand over a production-ready service, it was to prove the seams work: a new use case, a new adapter, or a new middleware layer should each have an obvious place to plug in. The "What to Build Next" list below is really a test of that claim — Chapter 6.10's auth middleware wraps the router with no changes to the handlers, and Chapter 6.11's rate limiter slots in the same way, which is only possible because HTTP concerns stayed in the adapter and never leaked into the domain.
What to Build Next
This project is a skeleton meant to be extended, not a finished toy. A few concrete next steps, each pointing at a chapter already in this book:
- Real webhook delivery. The
webhook.Publisherabove fires once with no retry logic. Chapter 6.23 covers signing payloads, retrying with backoff, and giving subscribers a replay endpoint — apply all three toOrderShippedandOrderCancelledbefore this goes anywhere near production traffic. - Authentication on every endpoint. Nothing in this chapter's HTTP
adapter checks who is calling. Chapter 6.10 covers token-based auth
end-to-end; wrap
adapterhttp.NewRouter's mux in that middleware before exposing any of this publicly. - Rate limiting per customer. A
POST /ordersendpoint with no limiting is an easy target for abuse. Chapter 6.11's token-bucket middleware slots in around the same router with no changes to the handler or use-case layers — a direct demonstration of why keeping HTTP concerns in the adapter, not the domain, pays off the moment you need to add one. - More use cases on the same aggregate.
Orderin Chapter 6.41 already has room for partial shipments, discounts, or aPlaceOrderuse case that validates product availability against an inventory service — each is a new use case and possibly a new domain service, never a change to howOrderprotects its own invariants. - A read model for reporting. The current design optimizes for protecting writes. A dashboard showing orders-per-day doesn't need to go through the aggregate at all — a separate, denormalized read path (a simple SQL view, or a materialized projection built from the domain events already being published) keeps reporting queries from pressuring the write-side schema.
Every one of these extensions plugs into a seam this chapter already built: a new middleware around the router, a new adapter implementing an existing port, or a new use case calling the same aggregate. That's the actual test of whether Chapters 6.39 through 6.42 did their job — not that the initial version works, but that the next six months of feature requests have an obvious place to go.
Key Takeaways
main.gois the one file allowed to import every layer — its entire job is constructing concrete adapters and handing them to use cases as interfaces, with no business logic of its own.- Tracing a single request (HTTP handler, use case, domain aggregate, repository, Postgres, and back) shows the architecture paying for itself: the business rule lives in exactly one place no matter which hop you inspect.
- Adapters for Postgres, Docker, and cloud deployment are direct applications of Chapters 6.36, 6.38, and 6.43 — this chapter's only new material is the wiring between them, not the technologies themselves.
- Keep deployment-platform awareness confined to
main.goand a thin platform/config package — letting it leak intointernal/domainrepeats the exact mistake Chapter 6.39 warned against, just with a newer technology. - This project is intentionally incomplete — auth, rate limiting, real webhook delivery, and a read model are the natural next additions, and each one has a seam already built for it.
postgres.OrderRepository.FindByIDcompletes the persistence adapter by callingdomain.Rehydratewithnilevents, since pending events are always drained and published before a save, never carried across a reload.paymentgateway.StubRefunderis a deliberate test-double adapter, not a real payment integration — it satisfiesdomain.Refunderso the rest of the system can be wired and run end-to-end before a real gateway exists.