net/go.book
All Parts Marketing

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, &cents, &currency); 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.

Don't let deployment concerns leak back into internal/
It's tempting to read a Cloud Run-specific environment variable, or branch on 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.Publisher above fires once with no retry logic. Chapter 6.23 covers signing payloads, retrying with backoff, and giving subscribers a replay endpoint — apply all three to OrderShipped and OrderCancelled before 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 /orders endpoint 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. Order in Chapter 6.41 already has room for partial shipments, discounts, or a PlaceOrder use 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 how Order protects 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.go is 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.go and a thin platform/config package — letting it leak into internal/domain repeats 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.FindByID completes the persistence adapter by calling domain.Rehydrate with nil events, since pending events are always drained and published before a save, never carried across a reload.
  • paymentgateway.StubRefunder is a deliberate test-double adapter, not a real payment integration — it satisfies domain.Refunder so the rest of the system can be wired and run end-to-end before a real gateway exists.