Eleventh part of the “Learning System Design” series! This time — the 12-Factor App. Or rather, why “12” stopped being enough about a decade ago, and what a modern Go microservice actually has to do today.

Finlayson weaving hall, 1932 — rows of identical looms stretching down the hall, a metaphor for many small factors composing into one well-tuned system

I keep running into the same situation. Someone joins a team, opens the README, reads “we follow 12-factor,” and assumes the conversation is done. Then we deploy and discover the service has no idempotency keys, no circuit breakers, no SBOM, env-var secrets in kubectl describe pod, prompts hard-coded in source files, and no canary strategy. Technically 12-factor — and yet the design is fifteen years behind reality.

So I sat down and reread the original manifesto, Hoffman’s 2016 follow-up, and a bunch of cloud-native postmortems. Came out the other end convinced the methodology is still right at the core — we just need to drop the number. Call it Many-Factor instead. Here’s the version I currently use for Go services: 23 factors and counting.

The Timeline

The 12-Factor App was written by Adam Wiggins at Heroku in 2011. No Kubernetes, no Docker yet (Docker came in 2013), no distributed tracing as a discipline, definitely no LLMs. It captured the hard-won wisdom of running thousands of SaaS apps on a PaaS — and it nailed the fundamentals.

2016. Kevin Hoffman wrote Beyond the Twelve-Factor App and added 3 more: API-first, Telemetry, Authentication & Authorization. The number quietly grew to 15.

2026. Software supply chain attacks (Log4Shell, xz-utils, SolarWinds) are weekly news. LLMs are commodity backing services. FinOps is a discipline. Progressive delivery is table stakes. AI-assisted development is the default. The number has to grow again — at least 8 more factors — and the truth is it’ll keep growing. So I dropped the number entirely.

Timeline showing factor count growing from 12 in 2011 to 15 in 2016 to 23 in 2026

The Original 12 — Still Right

I won’t bore you walking through all twelve. They’re at 12factor.net and they’ve aged well. Quick refresher with the parts where Go shines:

  • I. Codebase — one app, one logical codebase, many deploys. Monorepo or polyrepo, both fine. What breaks the factor is blurred app boundaries (copy-pasted “shared” code, or a binary built from merging two repos).
  • II. Dependenciesgo.mod + go.sum for compile-time, CGO_ENABLED=0 + FROM scratch for runtime. The scratch image is the spiritual home of this factor.
  • III. Config — separate from code. We’ll come back to this; env vars are no longer enough.
  • IV. Backing services — Postgres, Redis, S3, Kafka are just URLs in config.
  • V. Build, Release, Run — but really Develop → Build → Release → Run, one-way flow only. Editing a release in prod with kubectl edit is the cardinal sin.
  • VI. Processes — stateless. No in-memory sessions. Sticky sessions are an anti-pattern.
  • VII. Port binding — your Go binary IS the server. net/http was built for this.
  • VIII. Concurrency — scale by running more processes, not by making one bigger. Goroutines are a bonus on top, not a replacement.
  • IX. Disposability — fast boot, graceful shutdown. signal.Notify + srv.Shutdown(ctx) is six lines and saves you from corrupted in-flight work.
  • X. Dev/prod parity — same Postgres minor version locally and in prod. Testcontainers makes this trivial.
  • XI. Logs — JSON to stdout, never to a file. log/slog is stdlib since 1.21 — use it.
  • XII. Admin processes — migrations, backfills, one-off fixes run as separate binaries against the same release. Never kubectl exec; psql; UPDATE.
// log/slog — one line, structured, production-ready
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("payment processed", "user_id", userID, "amount", amount)

Nothing controversial here. If you’re not doing these, fix that before reading on.

Hoffman’s 3 Additions (2016)

Hoffman noticed three gaps once cloud-native took off:

XIII. API-First. Design the contract (OpenAPI, Protobuf) before writing the implementation. Generate types and server stubs with oapi-codegen. Consumer teams unblocked early, mock servers possible, no “the API is whatever the handler returns this week.”

XIV. Telemetry. Logs, metrics, traces. Treat your app like a spacecraft — you can’t SSH into it, the only signal you get is the telemetry it broadcasts. The big insight that took the industry a decade to internalize: logs are far more valuable when they carry a trace_id. That’s the bridge between pillars.

ctx, span := tracer.Start(ctx, "ProcessPayment")
defer span.End()
slog.InfoContext(ctx, "processing payment",
    "trace_id", span.SpanContext().TraceID().String(),
    "amount", p.Amount)

Five extra characters in your log line and your debug time drops from days to minutes.

XV. Authentication & Authorization. Not bolted on at the end. And — strongly — don’t build it yourself. Auth looks like “JWT + bcrypt, easy, weekend project,” and six months later you’re maintaining SAML, SCIM, TOTP, WebAuthn, audit logs, key rotation, breach detection, and a SOC 2 evidence pipeline. Auth0, Clerk, WorkOS, Keycloak, Ory — all of them solve this. Use one. Verify their JWTs with keyfunc + golang-jwt and move on with your life.

The 8 Modern Additions (2026)

This is where I think the methodology genuinely needed an update. Each of these costs real money and real outages when you skip it.

XVI. AI & LLMs as First-Class Backing Services

An LLM is just Postgres with extra steps. It’s a stateful-ish thing you talk to over the network, it has versioning quirks, it can fail or get slow, and it costs money per query. If you hard-code gpt-4o in your business logic, you’ve broken Factor IV all over again — but with a $10k/month bill.

type LLMProvider interface {
    Complete(ctx context.Context, req CompletionRequest) (*CompletionResponse, error)
}

// Fallback chain — degrade gracefully
for _, p := range []LLMProvider{primary, fallback, local} {
    if resp, err := p.Complete(ctx, req); err == nil {
        return resp.Text, nil
    }
}

Sub-rules I now treat as non-negotiable: prompts are code (version-control them in prompts/*.txt, diff them in PRs), evals are tests (a prompt change without an eval is a deploy without a test), and every LLM call emits a cost metric (llm_tokens_total{provider,model,kind}).

XVII. Resilience by Design

Every network call will fail. Every dependency will get slow. Every retry storm will happen at the worst moment. Naive code is a single copper wire — one break and you’re down. Resilient code wraps that wire in a fuse (timeout), a circuit breaker (stop trying when broken), a bulkhead (don’t let one slow dep drown the others), and a retry with exponential backoff + jitter.

Resilience patterns — timeout, retry, circuit breaker, bulkhead surrounding an external call

cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
    Name:        "payment-gateway",
    ReadyToTrip: func(c gobreaker.Counts) bool { return c.ConsecutiveFailures > 5 },
})

ctx, cancel := context.WithTimeout(ctx, 2*time.Second) // ALWAYS timeout
defer cancel()

return backoff.Retry(func() error {
    _, err := cb.Execute(func() (any, error) { return nil, gateway.Charge(ctx, p) })
    return err
}, backoff.WithContext(backoff.NewExponentialBackOff(), ctx))

The single most important line is context.WithTimeout. No external call without one. Ever.

XVIII. Idempotency & Exactly-Once Semantics

“Exactly once” doesn’t exist. “At-least-once + idempotent” does. You press “Charge $100”, the response times out — did the charge happen? If you retry, will you be charged twice?

func (h *Handler) CreatePayment(w http.ResponseWriter, r *http.Request) {
    idemKey := r.Header.Get("Idempotency-Key")
    if cached, ok := h.idemStore.Get(r.Context(), idemKey); ok {
        writeJSON(w, cached) // same response as the first attempt
        return
    }
    // ... process, then store the response by idemKey with a TTL
}

And in the database:

INSERT INTO payments (id, idempotency_key, amount, status)
VALUES ($1, $2, $3, 'pending')
ON CONFLICT (idempotency_key) DO UPDATE SET id = payments.id
RETURNING id, status;

Once you have this primitive, retries, replays, and disaster recovery all become safe. Without it, every distributed-system bug is a potential double-charge.

XIX. Supply Chain Security

Your binary is a Trojan horse waiting to happen. Every go get, every base image, every transitive dep is a potential xz-utils. The minimum stack:

syft scan dir:. -o spdx-json > sbom.json    # what's in there
cosign sign --yes ghcr.io/acme/payments:v1.4.2 # sign it
govulncheck ./...                            # scan Go code
trivy image ghcr.io/acme/payments:v1.4.2     # scan container

govulncheck is the one I want to highlight — it’s the Go team’s official tool, it understands which symbols you actually call (not just which packages you import), and it runs in seconds. Add it to CI and forget about it.

XX. Progressive & Declarative Delivery

Two ideas that compound. Declarative = describe what you want in Git, let a controller (ArgoCD, Flux) reconcile the cluster toward it. Progressive = don’t flip 100% of traffic at once. Ramp 1% → 10% → 100% with automated rollback when error rates regress.

GitOps loop — engineers merge PRs into Git, the controller pulls the cluster toward desired state

The key mental shift: deploy and release are two different events. Deploy puts the code on the cluster. Release exposes it to users. Feature flags decouple them:

useNewFraudModel, _ := client.BooleanValue(ctx, "fraud-model-v2", false,
    openfeature.NewEvaluationContext(p.UserID, map[string]any{
        "country": p.Country, "vip_tier": p.VIPTier,
    }))
if useNewFraudModel {
    return processWithFraudV2(ctx, p)
}
return processWithFraudV1(ctx, p)

A rollback becomes flipping a flag, not a re-deploy at 3am.

XXI. Cost & FinOps Awareness

Hardware used to be capex — buy once, depreciate. In the cloud, every API call has a price tag and an inefficient query is expensive forever, scaling linearly with traffic. With LLMs, a single prompt can cost 100× a Postgres query. You need a P&L per endpoint, not just latency.

var requestCostUSD = prometheus.NewHistogramVec(
    prometheus.HistogramOpts{Name: "request_cost_usd", Buckets: []float64{0.0001, 0.001, 0.01, 0.1, 1, 10}},
    []string{"endpoint", "tenant_id"},
)

Tag everything (CostCenter, Team, Service, Env), set SLOs for cost alongside latency, kill dev environments overnight. Untagged spend is unaccountable spend.

XXII. Schema & Contract Evolution

APIs, events, database schemas all change. Never break consumers. For databases this means the expand–migrate–contract pattern — three deploys for one logical breaking change:

-- Phase 1 (Expand): add new column, allow NULL
ALTER TABLE users ADD COLUMN email_normalized TEXT;

-- Phase 2 (Migrate): backfill, new code writes both, reads new
UPDATE users SET email_normalized = lower(trim(email));

-- Phase 3 (Contract): drop old column after all consumers migrated
ALTER TABLE users DROP COLUMN email_legacy;

Three deploys instead of one is annoying. Two-hour outage from a one-shot DROP COLUMN while old pods are still running is much worse.

XXIII. AI-Assisted Development

The factor I’d have laughed at three years ago. Today a huge fraction of the code in mature codebases is AI-assisted — generated, refactored, or reviewed by an LLM. The question isn’t should we use it — it’s how do we use it safely.

The dev workflow now looks like: Spec → AI generation → Human review → Tests/CI → Commit. The bottleneck shifted from typing speed to specification clarity and review judgment.

What I treat as non-negotiable:

  • Commit CLAUDE.md / AGENTS.md / .cursorrules at the repo root. Project conventions, build commands, test commands, “always use X for Y” rules. The assistant loads it every session.
  • Spec-first for non-trivial work. Write a markdown plan, AI implements against it. Reviewing a spec is much faster than reviewing 800 lines of generated diff.
  • Tests are non-negotiable. AI-generated code without tests is unverified text.
  • Never trust crypto or auth code from an LLM without an expert reading it.
  • Be explicit about model choice — claude-opus-4-7 for hard reasoning, claude-sonnet-4-6 for routine work.

The AI-friendly Go codebase is just an idiomatic Go codebase — small interfaces, clear errors, table-driven tests, package-level docs. Bad code is also hard for the AI to refactor safely.

What to Adopt First

If you’re standing in front of a fresh Go service today and asking what gives you the most bang for buck, my ranking:

  1. Telemetry (XIV) — without this nothing else matters when things break.
  2. Resilience (XVII) — biggest single source of outages in distributed systems.
  3. Supply chain security (XIX) — table stakes after Log4Shell and xz-utils.
  4. AI-assisted dev (XXIII) — biggest individual productivity gain available right now.
  5. Idempotency (XVIII) — saves you from data-corruption bugs that are nearly impossible to debug.
  6. Progressive delivery (XX) — biggest single reduction in deploy-day stress.

Factors I–XV are non-negotiable baseline. If you’re not doing them, fix that before adding XVI–XXIII.

Summary

The 12-Factor App was right in 2011 and it’s still right today — it just stopped being enough about a decade ago. The world added Kubernetes, then service meshes, then LLMs, then supply chain attacks, then FinOps, then AI-assisted development. The methodology has to grow with the world.

The 23 factors I use today fall into three buckets:

  • The original 12 (2011) — codebase, deps, config, backing services, build/release/run, processes, port binding, concurrency, disposability, dev/prod parity, logs, admin processes. Still right. Still non-negotiable.
  • Hoffman’s 3 (2016) — API-first, telemetry, AuthN/Z. The cloud-native era’s contribution.
  • The modern 8 (2026) — LLMs as services, resilience, idempotency, supply chain security, progressive delivery, FinOps, schema evolution, AI-assisted dev. The post-pandemic, post-Log4Shell, post-LLM contribution.

By 2030 there’ll probably be 30. That’s the whole point of dropping the number from the name.

The full guide with all 23 factors, Go-specific code examples, deeper trade-off tables, and links lives here:

https://github.com/sadensmol/learning_system-design/blob/main/many-factor-app-guide.md

Thanks for reading! More system design topics coming in the next parts of the series.

PS: which factor do you think your team is weakest on? I genuinely believe most Go shops have telemetry and resilience nailed but ship idempotency keys as an afterthought. Curious where you land — drop me a note.