Stage 7 · Master
Phase 20 — Production Readiness
API Versioning
Give the gateway a real versioning contract so a breaking change to one service's response shape never silently breaks every client depending on it.
Phase 5 built services/gateway-service to route /v1/* requests to the right upstream service, and every service since has added fields to responses without incident because adding a field is additive. That streak ends the day maintenance needs to rename amount_cents to amount with a currency object, or payment needs to change how a webhook signature is verified. This lesson gives the gateway an explicit contract for exactly that moment: how a breaking change ships without an unannounced outage for whichever client has not migrated yet.
Version at the Gateway, Not Inside Each Service
The gateway already strips a version prefix and forwards to an upstream; the missing piece is letting that prefix diverge — /v1/charges and /v2/charges reaching two different handlers inside the same maintenance binary — instead of assuming every route will always mean the same shape forever.
package v2
import (
"encoding/json"
"net/http"
"github.com/hoa-platform/backend/services/maintenance-service/internal/domain"
)
// chargeResponse replaces v1's flat amount_cents integer with a typed
// money object — the exact kind of change that cannot ship as an
// in-place edit to v1's handler without breaking every existing client
// parsing amount_cents today.
type chargeResponse struct {
ID string `json:"id"`
FlatID string `json:"flat_id"`
Amount moneyPayload `json:"amount"`
Status string `json:"status"`
}
type moneyPayload struct {
Cents int64 `json:"cents"`
Currency string `json:"currency"`
}
func newChargeResponse(charge domain.Charge) chargeResponse {
return chargeResponse{
ID: charge.ID.String(),
FlatID: charge.FlatID.String(),
Amount: moneyPayload{Cents: charge.AmountCents, Currency: "USD"},
Status: string(charge.Status),
}
}
func (h *ChargeHandler) Get(w http.ResponseWriter, r *http.Request) {
charge, err := h.service.FindByID(r.Context(), tenantFrom(r), chargeIDFrom(r))
if err != nil {
writeError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(newChargeResponse(charge))
}v1's handler package is untouched by this file — the two response shapes coexist in the same binary because they live in separate packages, not because one was edited in place to support both.
package proxy
import "net/http"
// Route maps a URI version prefix to the upstream service and the
// deprecation metadata that applies to every request under that
// prefix. A route with no deprecatedAt is fully supported; one with
// deprecatedAt but no sunsetAt is deprecated but not yet scheduled for
// removal; one with both is on a clock.
type Route struct {
Prefix string
Upstream string
DeprecatedAt string // RFC 1123, empty if not deprecated
SunsetAt string // RFC 1123, empty if no removal date is set
}
var chargeRoutes = []Route{
{Prefix: "/v1/charges", Upstream: "http://maintenance.hoa.svc.cluster.local:8080/v1/charges",
DeprecatedAt: "Mon, 01 Apr 2024 00:00:00 GMT", SunsetAt: "Mon, 01 Jul 2024 00:00:00 GMT"},
{Prefix: "/v2/charges", Upstream: "http://maintenance.hoa.svc.cluster.local:8080/v2/charges"},
}
func withDeprecationHeaders(route Route, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if route.DeprecatedAt != "" {
w.Header().Set("Deprecation", route.DeprecatedAt)
}
if route.SunsetAt != "" {
w.Header().Set("Sunset", route.SunsetAt)
w.Header().Set("Link", `</v2/charges>; rel="successor-version"`)
}
next.ServeHTTP(w, r)
})
}The Deprecation and Sunset headers (RFC 8594) are attached at the gateway, once, for every route under a prefix — an individual service is never responsible for remembering to announce its own deprecation on every handler.
Ship the Breaking Change With Expand-Contract, Not a Flag Day
v1 and v2 cannot simply appear simultaneously the day v2 ships — the underlying maintenance.charges table has one amount_cents column, and both handler versions read from it. Expand-contract sequences the database change, the dual-read handlers, and the eventual removal so that at every step, both API versions keep returning correct data from a single source of truth.
- Expand — add the new column alongside the old one; backfill it; every write updates both columns so they never disagree.
- Migrate — v2's handler reads the new column; v1's handler keeps reading the old column, both against the same row.
- Verify — confirm zero v1 traffic remains, using the Deprecation header's access logs as evidence, not a guess.
- Contract — drop the old column only after verification, in its own migration, never combined with the expand step.
ALTER TABLE maintenance.charges ADD COLUMN amount_currency text;
ALTER TABLE maintenance.charges ADD COLUMN amount_minor_units bigint;
UPDATE maintenance.charges
SET amount_currency = 'USD', amount_minor_units = amount_cents
WHERE amount_currency IS NULL;
-- A trigger, not application code, keeps both representations in sync
-- for every future write, including ones from v1 handlers that never
-- learn the new column exists.
CREATE OR REPLACE FUNCTION maintenance.sync_charge_amount() RETURNS trigger AS $$
BEGIN
NEW.amount_currency := coalesce(NEW.amount_currency, 'USD');
NEW.amount_minor_units := NEW.amount_cents;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER charges_sync_amount
BEFORE INSERT OR UPDATE ON maintenance.charges
FOR EACH ROW EXECUTE FUNCTION maintenance.sync_charge_amount();
The sync trigger means v1 handlers — which only ever set amount_cents and were never touched by this lesson — keep the new columns correct automatically, so v2 can read amount_minor_units safely from the moment this migration lands, before a single line of Go changes.
DROP TRIGGER IF EXISTS charges_sync_amount ON maintenance.charges;
DROP FUNCTION IF EXISTS maintenance.sync_charge_amount();
ALTER TABLE maintenance.charges DROP COLUMN IF EXISTS amount_currency;
ALTER TABLE maintenance.charges DROP COLUMN IF EXISTS amount_minor_units;
The trigger and its function are dropped before the columns they reference, so a partial rollback never leaves a trigger pointing at a function or column that no longer exists.
Dropping amount_cents in the same migration that adds amount_minor_units would break every v1 client instantly, on deploy, with no window to observe traffic or roll back safely — exactly the flag-day cutover this pattern exists to avoid. The contract step is only safe once access logs prove zero requests are still arriving under the deprecated prefix, and it belongs in its own migration file, deployed independently, so it can be delayed without blocking the expand step that unblocked v2 in the first place.
Verify Zero v1 Traffic Before the Contract Step Ships
The Sunset header set a date; it does not enforce one. The only way to know the contract migration is safe is to measure actual traffic against the deprecated prefix and confirm it has reached zero, not assume every client read the header and migrated on schedule.
Applied exercise
Design the expand-contract sequence for a payment-service breaking change
services/payment-service/internal/payment's v1 webhook handler verifies signatures using a shared HMAC secret; v2 needs to move to per-tenant signing keys stored in a new payment.signing_keys table, without breaking any in-flight v1 webhook senders during the migration.
- Write the expand migration: the new signing_keys table and whatever backfill or trigger keeps v1's shared-secret verification working unchanged while it exists.
- Describe the migrate step: which handler package changes, and what stays byte-for-byte identical in v1's package.
- Write the gateway Route entries and header values needed to announce the v1 webhook path as deprecated with a concrete sunset date six weeks out.
- Write the verification query or log check that must show zero results before the contract migration (dropping the shared secret) is allowed to ship.
Deliverable
An expand migration file, a short migrate-step description naming the untouched v1 package, filled-in Route entries with Deprecation/Sunset values, and a concrete verification command.
Completion checks
- The expand migration is purely additive and includes a mechanism keeping v1 verification working without any Go code changes.
- The migrate step names the exact new package v2 lives in and confirms v1's package is not edited.
- The verification step checks actual measured traffic, not merely the passage of the sunset date.
Why does maintenance's v2 charge handler live in its own package (internal/http/v2) rather than adding a version parameter to the existing v1 handler?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.