My Go Learning Journey
Not a syntax course. A path from code → concept → problem → use case → tradeoff → failure mode → operational impact → architecture decision — so I can read a requirement, weigh constraints, choose technologies, and predict production behavior.
REST vs gRPC
Two philosophies for the same problem: two programs need to talk, and need an agreed contract + a way to turn data into bytes.
Resources over HTTP
Everything is a resource you act on with HTTP verbs. Data is usually JSON — text, human-readable.
- Shared vocabulary; works everywhere (curl, browser)
- Human-readable & debuggable
- Verbose/slow JSON, no enforced schema
- No native streaming; easy to over-fetch
Typed calls over HTTP/2
Contract lives in a .proto file; a compiler generates client + server code. Data is compact binary Protobuf.
rpc GetUser(Req) returns (User);
}
- Enforced schema — single source of truth
- Small/fast payloads + codegen
- First-class streaming (HTTP/2)
- Not human-readable; browsers need a proxy
Serialization — what actually crosses the wire
The single decision that cascades into every other difference: self-describing text vs schema-agreed binary.
The four call shapes — gRPC's biggest edge
When to reach for which
| Situation | Pick |
|---|---|
| A browser or third party calls it | REST |
| You want human-readable / debuggable | REST |
| Simple CRUD, broad compatibility | REST |
| Two of your own services talk | gRPC |
| Max speed + small payloads | gRPC |
| You need streaming or a strict schema | gRPC |
Go vs Java (Spring Boot) vs Python
Three healthy ecosystems — the difference is philosophy and sweet spot, not "better/worse".
The cloud's plumbing
- Small language, one obvious way to do things
- Concurrency (goroutines) is idiomatic, not bolted on
- Ships as a single static binary
- Network services, APIs, CLIs, infra tooling
- Minimal by design; verbose error handling
Enterprise backbone
- Mature, batteries-included framework
- Deep DI, ORM, huge library ecosystem
- Rock-solid for large business systems
- Complex domain logic, big teams
- Heavy JVM footprint; more boilerplate/"magic"
Speed of development
- Readability + fast prototyping; glue language
- Unbeatable for data science & ML/AI
- Enormous ecosystem (pandas, PyTorch)
- Data pipelines, scripting, quick tools
- Slower runtime; GIL limits CPU parallelism
Benefit for DevOps / SRE
Where Go's design choices pay off operationally — and why the cloud tooling world is written in it.
What ships to production
Why it fits ops
- Tiny containers (scratch/distroless), trivial deploys
- Fast startup + low RAM → great for autoscaling/serverless
- Built-in cross-compilation for CI/CD & multi-arch
- Goroutines suit agents/collectors watching thousands of things
- No GIL, no long GC pauses → predictable under load
It's the ecosystem's native tongue
These are all written in Go:
- Kubernetes · Docker · containerd
- Terraform · Helm
- Prometheus · etcd
Knowing Go means you can read and extend the tools you operate.
Go Mental Model
Read Go's feature set as a catalogue of answers to backend problems — every construct exists to solve a specific concurrency, coupling, or lifecycle failure you already know from production.
Data & type shape
Structs — solves “model data without a class-and-heap tax”
- What
- A named aggregate of typed fields —
type Order struct { ID string; Total int64 }. Value type: assignment and function calls copy it by default. No constructors, no inheritance, no hidden vtable. - Problem it solves
- Predictable, flat memory layout for the DTOs and domain entities that flow through your handlers, queues, and RDS/DynamoDB rows — without the reference-semantics surprises of Python objects or JS objects where everything is a shared pointer.
- Tradeoffs
- Value semantics mean big structs get copied on every pass unless you use a pointer. Zero value is always valid (all fields zeroed), which is a feature, but it also means “uninitialized” and “deliberately empty” look identical — model presence explicitly when it matters.
- In production
- Struct tags (
json:"total",db:"total") drive marshalling for API payloads and SQL scanning — one struct is your wire, storage, and in-memory shape. Field alignment affects cache behavior in hot paths but rarely matters until profiling says so. - Architecture impact
- Pushes you toward explicit, boundary-aware data models: separate structs for API request, domain, and persistence rather than one god-object mutated everywhere.
Interfaces — solves “depend on behavior, not implementations”
- What
- A set of method signatures. Satisfaction is structural and implicit: any type with those methods is the interface — no
implementskeyword, no import of the interface to satisfy it. - Problem it solves
- Dependency inversion and decoupling. Your order service depends on a
PaymentGatewayinterface it declares itself; Stripe/Adyen adapters satisfy it without knowing the interface exists. This is the seam for testability (swap a fake), for vendor swaps, and for mocking network calls out of unit tests. - When to use
- At the consumer boundary, for anything that does I/O or crosses a process line (DB, cache, queue, external API, clock). “Accept interfaces, return structs.”
- When NOT to
- Don't define an interface with one implementation “for the future” — that's speculative abstraction. Add it when the second implementation (or the test double) actually appears.
- Tradeoffs
- Implicit satisfaction is flexible but you can't grep for “who implements this.” Small interfaces (1–2 methods) compose well; wide interfaces couple everything to every method. An interface value carries a type+data pointer, so it costs an indirection and can force a heap allocation.
- Architecture impact
- The single most important tool for keeping a service's core independent of AWS SDKs, Kafka clients, and RDS drivers — the hexagonal/ports-and-adapters seam lives here.
Pointers — solves “mutate in place & control copies without a GC-language's opacity”
- What
*Tis an address of aT;&xtakes one. Go has pointers but no pointer arithmetic — safe references, not C.- Problem it solves
- Explicit choice between value semantics (copy, isolation, no shared mutation) and pointer semantics (share one instance, mutate it, avoid copying large structs). In Python/TS this choice is made for you (objects are always references) — Go makes it a design decision per type.
- When to use
- Pointer receivers when a method mutates or the struct is large; pointers to share a single stateful thing (a connection pool, a config) across goroutines.
- When NOT to
- Don't reach for a pointer to a small immutable value — you add nil-risk and an indirection for nothing.
nilpointer dereference is Go'sNullPointerException, and it's a panic that can take down a request or a whole process. - Tradeoffs
- Escape analysis: taking a pointer to a local can force it from the stack to the heap, adding GC pressure. This is invisible until
go build -gcflags=-mor a pprof heap profile shows allocations spiking under load. - Architecture impact
- Shared mutable state behind a pointer is exactly what must be protected once goroutines are involved — pointers are where value/pointer semantics meet the concurrency model.
Methods & receivers — solves “attach behavior to data, decide copy vs share per call”
- What
- A function with a receiver:
func (o Order) Total()(value receiver) vsfunc (o *Order) Apply()(pointer receiver). The receiver kind is part of the API contract. - Problem it solves
- Behavior lives next to data without inheritance. Pointer receivers let a method mutate the instance; value receivers guarantee the method can't (useful for read-only, concurrency-safe accessors).
- Tradeoffs
- Mixing value and pointer receivers on one type is a classic bug: only
*Tsatisfies an interface if any method has a pointer receiver, and a value copy silently drops mutations. Pick one receiver style per type. - In production
- A value receiver on a large struct copies it on every call — a hot-path method can show up as surprising CPU/alloc in a profile.
- Architecture impact
- Method sets are how a type satisfies interfaces, so receiver choice quietly determines what your type can plug into.
Packages & modules — solves “enforce boundaries & reproducible dependencies”
- What
- A package is the unit of compilation and visibility (exported = Capitalized). A module (
go.mod) is the unit of versioning and dependency resolution, with a checksummedgo.sum. - Problem it solves
- Deterministic builds (minimal version selection + checksums, no lockfile drift like
node_modulessurprises) and compiler-enforced module boundaries: aninternal/directory is importable only within its own module subtree, so you can hard-wall a domain from the rest of a monorepo. - When NOT to
- Don't over-fragment into micro-packages that cause import cycles (Go forbids them outright) — cycles are a design smell forcing you to find the real dependency direction.
- Tradeoffs
- No circular imports is a hard constraint; you refactor to a shared lower package or invert with an interface. Version selection is conservative (lowest compatible), which is reproducible but means upgrades are deliberate.
- In production
go.sumis your supply-chain integrity check; vendoring or a module proxy (Athens, GOPROXY) gives you build reproducibility and an audit surface for CVE scanning.- Architecture impact
internal/boundaries let a single repo hold multiple services/domains without accidental coupling — the compiler is your architecture-fitness test.
Errors & lifecycle
Error handling — solves “make failure a first-class, explicit value”
- What
- Errors are ordinary values returned alongside results (
v, err := f()), not thrown. Theerrorinterface is one method:Error() string. Wrap withfmt.Errorf("...: %w", err); inspect witherrors.Is/errors.As. - Problem it solves
- No invisible control-flow jumps. Every failure is at the call site, in the signature — you cannot forget that a function can fail the way an unchecked exception lets you. Wrapping builds a causal chain (context at each layer) instead of a raw stack trace.
- When NOT to
panicis for programmer bugs and truly unrecoverable state, not for expected failures like “row not found” or “429 from upstream.” Recover only at process/goroutine boundaries.- Tradeoffs
- Verbose:
if err != nileverywhere. The upside is that error paths are as visible and reviewable as happy paths — the code where outages hide is no longer implicit. Lose the wrapping (barereturn err) and you lose the breadcrumb trail. - In production
- Wrapped errors +
errors.Is(err, ErrRateLimited)drive retry/circuit-breaker decisions and structured log fields. Sentinel errors and typed errors let you map to HTTP/gRPC status codes deterministically. - Architecture impact
- Forces explicit failure taxonomies at every boundary — the foundation for retries, dead-letter queues, and idempotency handling in an event-driven system.
defer — solves “guaranteed cleanup regardless of exit path”
- What
defer f()schedulesfto run when the surrounding function returns — including on panic. Deferred calls run LIFO; arguments are evaluated at thedeferstatement, not at execution.- Problem it solves
- Resource leaks from early returns. Close the DB rows, release the mutex, close the file, end the tracing span, decrement a semaphore — right next to where you acquired it, once, instead of at every return branch (the
finally-everywhere problem). - When NOT to
- Don't
deferinside a tight loop expecting per-iteration cleanup — deferred calls stack until the function returns, so a loop over 100k rows that defersrows.Close()leaks all of them until the end. Wrap the body in a closure or close explicitly. - Tradeoffs
- Tiny per-call overhead (negligible outside microbenchmarks) in exchange for correctness. Deferred error returns need the named-return-value trick to surface a close error.
- In production
- The canonical guard against connection-pool exhaustion and fd leaks — the leak that looks fine in dev and OOMs/hits
too many open filesunder sustained load. Also where you reliably flush spans/metrics on every path. - Architecture impact
- Makes resource lifetime lexically scoped, so ownership is obvious — critical when the same handler can exit via success, validation error, or context cancellation.
Context — solves “propagate cancellation, deadlines & request scope across a call tree”
- What
context.Contextis a request-scoped carrier for cancellation signals, deadlines/timeouts, and a small amount of request metadata. It threads as the first argument through every call that might block or spawn work.- Problem it solves
- When a client disconnects or a 200ms deadline is blown, every downstream goroutine, DB query, and gRPC/HTTP call in that request tree must stop — otherwise you do work nobody is waiting for and pile up goroutines. Context is the cancellation bus that makes that propagation automatic.
- When to use
- Every I/O boundary and every goroutine you spawn per request. Set deadlines at ingress and at each hop (
context.WithTimeout). - When NOT to
- Don't stuff business parameters into context values — it's for cancellation and cross-cutting metadata (trace IDs, auth), not a typed-argument replacement.
- Tradeoffs
- Ubiquitous plumbing —
ctxas first arg everywhere. The payoff is that graceful shutdown, timeout budgets, and cascading cancellation are built-in rather than bolted on. - In production
- Deadline propagation implements timeout budgets across a microservice chain (Istio/Envoy timeouts align with ctx deadlines). On SIGTERM (K8s pod eviction), cancelling the root context drains in-flight requests for graceful termination inside
terminationGracePeriodSeconds. - Architecture impact
- The backbone of distributed request lifecycle management — cancellation, deadline budgets, and trace propagation all ride the context.
Concurrency
select the multiplexer, and sync the escape hatch for plain shared state. The rest of this section is that toolkit and when each tool wins.
Goroutines — solves “massive cheap concurrency for I/O-bound work”
- What
go f()starts a goroutine: a function scheduled onto OS threads by the Go runtime. Starts at ~2KB stack (grows on demand), so hundreds of thousands are feasible where OS threads would die at thousands.- Problem it solves
- High-concurrency services that are I/O-bound — an API gateway holding 50k idle connections, a fan-out of concurrent downstream calls, background workers. The runtime multiplexes goroutines over a few threads and parks them on I/O, so you write blocking-style code that scales like async, without callback/async-await coloring.
- When NOT to
- CPU-bound crunching gets no free lunch — you're bounded by
GOMAXPROCScores. And never fire an unbounded number per request; that's how you get resource exhaustion. - Tradeoffs
- Leaks: a goroutine blocked forever on a channel/read with no cancellation never dies. It's the #1 Go production bug — invisible until
go_goroutines(Prometheus) climbs monotonically and the process OOMs. Every goroutine needs a guaranteed exit (context cancellation or a closed channel). - In production
- Watch goroutine count as a first-class metric; a pprof goroutine dump names the exact blocked stack. In containers, set
GOMAXPROCSto the CPU limit (automaxprocs) or the scheduler assumes host cores and thrashes. - Architecture impact
- Makes per-request concurrency and fan-out the default idiom — but demands disciplined lifecycle ownership so concurrency doesn't become a leak.
Channels — solves “hand off data & ownership safely between goroutines”
- What
- A typed conduit:
ch := make(chan T)(unbuffered = synchronous rendezvous) ormake(chan T, n)(buffered). Sendch <- v, receivev := <-ch. Closing signals “no more values.” - Problem it solves
- Communicate-by-sharing: instead of a shared struct + lock, you pass the data (and with it, ownership) through the channel — the receiver now owns it, so there's nothing to race over. Natural for pipelines (stage → stage), fan-out/fan-in, and signalling.
- When to use
- Transferring ownership of work items, streaming results, orchestrating stages, or signalling events (done/close). Buffered channels also act as a bounded queue = backpressure.
- When NOT to
- For guarding a counter or a map that many goroutines just read/update in place, a
Mutexis simpler and faster than routing every access through a channel. - Tradeoffs
- Ownership discipline matters: the sender closes (never the receiver), closing twice or sending on a closed channel panics. Unbuffered channels couple sender and receiver timing (a rendezvous), which can hide as latency.
- In production
- Buffered channel depth is a tuning knob for burst absorption; a full channel is backpressure surfacing — measure queue depth like you'd watch SQS
ApproximateNumberOfMessages. - Architecture impact
- In-process analogue of a message queue — pipelines and worker dispatch modeled as typed streams with explicit ownership handoff.
select — solves “multiplex, time out & cancel across channels”
- What
selectblocks on multiple channel operations and proceeds with whichever is ready (random if several are). Adefaultcase makes it non-blocking.- Problem it solves
- The composition primitive for concurrency: wait on work or
ctx.Done()(cancellation), or atime.After(timeout), or a shutdown signal — all in one place. This is how a goroutine gets a guaranteed exit and how timeouts are enforced. - When to use
- Any long-lived goroutine's main loop; implementing timeouts, cancellation, fan-in of several channels, or a non-blocking try-send.
- Tradeoffs
- Easy to write a
selectwith no cancellation case and reintroduce a goroutine leak.time.Afterin a hot loop allocates a timer each pass — use a resettime.Timer. - Architecture impact
- Where cancellation (Context), timeouts, and work multiplexing converge — the control point for graceful, deadline-aware goroutines.
sync primitives (overview) — solves “coordinate shared state when channels are the wrong tool”
- What
- The
syncpackage:Mutex/RWMutex(mutual exclusion),WaitGroup(wait for N goroutines),Once(exactly-once init),Cond, plussync/atomicfor lock-free counters. - Problem it solves
- Not everything is a data handoff. Sometimes you genuinely have shared state (a cache, a metrics counter, a config reloaded once) accessed by many goroutines — locks and atomics protect it directly, cheaper and clearer than forcing it through a channel.
- When to use
Oncefor lazy singletons (a connection pool).RWMutexfor read-heavy shared caches.atomicfor hot counters.WaitGroupto join fan-out.- Tradeoffs
- Locks reintroduce the classic hazards: deadlock, lock contention, holding a lock across I/O (never do this). The rule of thumb: channels for orchestration/ownership, sync for protecting a small piece of shared state.
- In production
- Lock contention shows as latency with idle CPU;
go tool pprofblock/mutex profiles pinpoint it.go test -racein CI is non-negotiable — data races are heisenbugs. - Architecture impact
- The pragmatic complement to channels; most real services use both — channels at the seams, locks inside a component.
WaitGroup — solves “wait for a fan-out to finish”
- What
wg.Add(n), each goroutinedefer wg.Done(), the coordinatorwg.Wait()blocks until the counter hits zero.- Problem it solves
- Fan-out/join: dispatch N concurrent tasks (parallel downstream calls, per-shard queries) and block until all complete before aggregating or returning. The JS
Promise.allshape, without promises. - When NOT to
- When you also need the results or the first error, pair it with a channel or use
errgroup(which adds context cancellation + first-error propagation). WaitGroup alone only answers “are they all done.” - Tradeoffs
- Miss an
Add/Donepairing and you deadlock onWait()or return early. CallingAddinside the spawned goroutine is a classic race — alwaysAddbeforego. - Architecture impact
- The join half of scatter-gather;
golang.org/x/sync/errgroupis the production upgrade for request-scoped fan-out with cancellation.
Mutex — solves “protect shared mutable state from concurrent access”
- What
sync.Mutexgives one goroutine exclusive access to a critical section (mu.Lock()/defer mu.Unlock()).RWMutexallows many readers or one writer.- Problem it solves
- When state must be shared in place (an in-memory cache, a rate-limiter's token bucket, a metrics map), a mutex serializes access so reads/writes don't interleave into corruption — without the overhead and copying of moving that state through a channel.
- When to use
- Small, fast critical sections around shared data structures; read-mostly state via
RWMutex. - When NOT to
- Never hold a lock across I/O or an RPC — you serialize the whole service on network latency. If the pattern is really “hand work to a worker,” a channel is the better model.
- Tradeoffs
- Contention becomes a scaling ceiling; a global lock turns a multi-core box single-threaded. Deadlock risk with multiple locks (always acquire in a fixed order). Simpler and lower-latency than channels for pure state protection.
- In production
- Mutex/block pprof profiles reveal contention hotspots; symptom is p99 latency rising while CPU sits idle. Shard the lock (or the map) to scale.
- Architecture impact
- Keeps shared-state components correct on multi-core — but its contention profile often argues for sharding or moving to a channel/actor model.
Worker pools — solves “bound concurrency, apply backpressure, cap resource use”
- What
- A fixed set of N worker goroutines all receiving from one jobs channel; the channel's buffer bounds the queue. The composition of goroutines + a channel +
WaitGroup. - Problem it solves
- Unbounded
go f()per request/message will exhaust memory, DB connections, or downstream rate limits under a spike. A pool caps in-flight work to N, and a full jobs channel exerts backpressure back to the producer instead of melting the system. - When to use
- Consuming from SQS/Kafka at a controlled rate, limiting concurrent DB writes to pool size, throttling calls to a rate-limited third party, any producer/consumer with a resource ceiling.
- When NOT to
- For a handful of one-off parallel calls,
errgroupwith a concurrency limit is simpler than a standing pool. - Tradeoffs
- Pool size is a real capacity-planning decision — too small underutilizes, too large overruns downstream limits. Must wire context cancellation so workers drain on shutdown, or you leak them.
- In production
- Pool size aligns with the DB
max_connections/ downstream QPS budget; queue depth is your saturation signal (mirror of SQS backlog). This is how you keep a consumer from DDoSing its own dependencies. - Architecture impact
- Turns raw goroutine concurrency into a governed, back-pressured resource — the in-process rate limiter that protects shared dependencies.
Design & abstraction
Generics — solves “type-safe reuse without interface{} & runtime casts”
- What
- Type parameters with constraints (Go 1.18+):
func Map[T, U any](s []T, f func(T) U) []U. Constraints are interfaces describing the allowed type set. - Problem it solves
- Before generics, reusable containers/algorithms used
interface{}+ type assertions — losing compile-time safety and paying boxing/allocation costs. Generics give you one implementation that stays type-checked across types. - When to use
- Generic data structures (sets, LRU caches, typed pools), collection helpers (Map/Filter/Reduce), and constraint-based numeric code — places where the logic is identical across types.
- When NOT to
- Don't reach for generics where a plain interface expresses behavior better (an interface is about what a type does; generics are about writing one algorithm over many types). Over-generic code is harder to read than two concrete functions.
- Tradeoffs
- Adds cognitive load and can slow compilation; the runtime uses GC-shape stenciling, so the perf win over
interface{}is real but not always dramatic. Interfaces still win for polymorphic dependency inversion. - Architecture impact
- Mostly a library-layer concern — safer shared utilities — not a reason to redesign service boundaries (that's still interfaces).
Dependency injection — solves “testable, swappable wiring without a framework”
- What
- Constructor injection: a component takes its dependencies as parameters (usually interfaces) —
NewOrderService(repo Repository, pay PaymentGateway) *OrderService. Wiring happens inmainor a small provider; no annotations, no reflection container. - Problem it solves
- Decoupling construction from use so you can inject fakes in tests, swap a DynamoDB repo for an in-memory one, or reconfigure per environment — without the Spring/NestJS DI-container magic that hides the object graph.
- When to use
- Everywhere a component does I/O or has environment-specific behavior. It's the practical payoff of the interfaces you defined earlier.
- Tradeoffs
- Manual wiring in
maingrows; large graphs use codegen (google/wire) rather than a runtime container — the tradeoff is more boilerplate for a fully explicit, compile-checked object graph (no runtime “bean not found”). - In production
- Explicit construction means startup failures are compile/boot errors, not lazy runtime surprises mid-request. The dependency graph is greppable and reviewable.
- Architecture impact
- Combined with interfaces, this is the ports-and-adapters composition root — the service core stays pure, and infrastructure is plugged in at the edge.
Composition over inheritance — solves “reuse & extend behavior without fragile class hierarchies”
- What
- Go has no inheritance. It offers struct embedding (promote an embedded type's fields/methods) and small interfaces composed of one another (
io.ReadWriteCloser=Reader+Writer+Closer). - Problem it solves
- The deep-hierarchy problems you've hit in Java/TS — fragile base classes, diamond ambiguity, “override the wrong method,” behavior scattered up an inheritance chain. Embedding gives reuse via has-a delegation; interfaces give polymorphism via structural satisfaction.
- When to use
- Embed to reuse (a
BaseHandler's logging/auth helpers, or async.Mutexinto a type). Define narrow interfaces at consumers and compose them; middleware/decorator patterns wrap behavior instead of subclassing it. - When NOT to
- Don't fake inheritance by embedding to build tall type towers — embedding is delegation, not an is-a relationship, and method promotion has no virtual dispatch.
- Tradeoffs
- No template-method “override” from a base type; you compose explicitly instead, which is more code but no hidden dispatch. Small interfaces mean more names but far looser coupling.
- Architecture impact
- Steers the whole codebase toward flat, decoupled components with explicit wiring — the structural reason Go services resist the god-class/deep-hierarchy rot.
Check yourself
You have an in-memory rate-limiter's token bucket read and updated by thousands of request goroutines. Reach for a channel or a mutex?
Mutex (or sync/atomic for a pure counter) is simpler and lower-latency. Option 3 is a data race: plain maps under concurrent writes corrupt and go test -race will (correctly) scream.
Distributed Systems Fundamentals
The moment your Go service calls another service over a network, the rules of local programming stop applying — and every architecture decision downstream is a reaction to that fact.
Running example — one hop, six ways it breaks
A Go Order Service takes a request and calls Payment Service synchronously. One edge. Watch how much complexity hides in it.
Reveal each case for what actually happens and the SRE / architecture consequence.
(a) Payment is slow (responds in 8s instead of 80ms)
What happens: Order Service's goroutine blocks on the call. Each slow request holds a goroutine, a connection, and any upstream client waiting on it. Under load, in-flight requests pile up faster than they drain.
Consequence: Slowness is contagious. Without a timeout the Order Service's latency becomes Payment's latency, its connection pool and goroutine count climb, and the client's own callers start timing out. Latency, not errors, is the most common trigger of cascading failure. You need a deadline on the call and headroom in your concurrency limits.
(b) Payment is unavailable (connection refused / DNS fails)
What happens: The call errors fast (good) or hangs on connect until the OS timeout (bad). Order Service must decide: fail the order, queue it, or degrade.
Consequence: A hard dependency failure. If payment is on the critical path, orders cannot complete — this is where you decide between fail-closed (reject the order) and graceful degradation (accept the order, capture payment asynchronously). A circuit breaker stops you from hammering a dead dependency and turning its outage into your outage.
(c) Request succeeds but the response is lost
What happens: Payment charges the card, then the network drops the reply (or Order Service times out one millisecond too early). Payment thinks "done"; Order Service thinks "failed."
Consequence: This is the partial failure that defines distributed systems. The caller has genuine uncertainty — it cannot distinguish "never happened" from "happened but I didn't hear." You cannot solve this with a smarter timeout; you solve it with idempotency so a safe retry converges to the correct single outcome.
(d) Client (or Order Service) retries
What happens: On timeout, the caller resends. Reasonable — most transient failures clear on retry. But the retry rides on top of case (c)'s uncertainty.
Consequence: Retries convert an at-most-once attempt into at-least-once delivery. Under a partial outage, naive retries multiply load on an already-struggling dependency — a retry storm. You need exponential backoff + jitter to spread the load, a bounded retry budget, and idempotency so the retry is safe.
(e) Payment is processed twice (double charge)
What happens: The retry from (d) lands on a Payment Service that already succeeded in (c). Two charges, one order.
Consequence: A correctness bug born from an availability mechanism. The fix is an idempotency key — a client-generated ID (Idempotency-Key header) that Payment stores; a repeat of the same key returns the original result instead of charging again. This is the single most important pattern for financial and mutating operations.
(f) Payment (or its DB) is overloaded
What happens: Payment is at capacity. New requests queue internally, latency spikes for everyone, and eventually it falls over — taking healthy callers down with it.
Consequence: The failure cascades upstream. Payment protects itself with load shedding (reject excess early with 429/503 rather than accept-and-die) and backpressure (signal callers to slow down). Order Service protects itself with a bulkhead (isolated connection pool so a slow Payment can't starve unrelated work) and a circuit breaker. The lesson: a healthy system with a sick dependency must actively defend itself.
The vocabulary of distribution
Grouped by the problem they attack. Each: what it is, the problem it solves, the tradeoff, and the operational/architecture footprint.
The physics — what the network does to you
Network latency
Round-trips cost real time (µs same-host, ms same-AZ, tens of ms cross-region). It is additive along a call chain and dominated by the tail (p99), not the mean.
Impact: chatty designs (N calls per request) multiply the tail. Batch, cache, or co-locate. Latency budgets are an architecture constraint, not an afterthought.
Network failures
Packets drop, connections reset, DNS fails, an AZ partitions. The network is unreliable by default — one of the classic fallacies to unlearn.
Impact: every remote call needs an explicit failure path. "It'll probably work" is not a design.
Partial failures
Part of a distributed operation succeeds while another part fails or is unknown — the defining hazard. No local analogue exists.
Impact: forces idempotency, reconciliation, and designs that tolerate uncertainty rather than assume atomicity.
Bounding the wait — timeouts & deadlines
Timeouts — "stop waiting after T"
- What
- A per-call cap on how long you'll block before giving up.
- Problem it solves
- Turns an unbounded hang (case a) into a fast, handleable error. Without one, slowness propagates and exhausts goroutines/connections.
- When to use
- Every remote call. No exceptions.
- Tradeoffs
- Too short → false failures + retry amplification on a healthy-but-slow dependency. Too long → you hold resources during an outage. Set from the p99, not the mean.
- In production
- Per-call timeout < the caller's own timeout, so you fail before your caller does.
Deadlines — "be done by time T", propagated
- What
- An absolute point in time carried through the call chain, not a fresh per-hop duration. In Go this is
context.ContextwithWithDeadline/WithTimeout. - Problem it solves
- Prevents wasted work: if the top-level request already blew its budget, every downstream hop should abandon immediately instead of doing doomed work.
- When to use
- Multi-hop chains — pass
ctxeverywhere and checkctx.Err(). gRPC propagates deadlines automatically. - Tradeoffs
- Requires disciplined
ctxplumbing; a dropped context breaks cancellation for the whole subtree. - Architecture impact
- Deadline propagation is why Go's
contextis threaded through every function signature — it's the cancellation and budget backbone.
Recovering from transient failure — retries done right
Retries — try again on transient errors
- What
- Re-issue a failed request expecting a different outcome.
- Problem it solves
- Most network failures are transient; a retry often just works.
- When NOT to
- Non-idempotent writes without a key (case e), or errors that won't clear (400/404). Never retry a 4xx.
- Tradeoffs
- Turns at-most-once into at-least-once; can trigger a retry storm.
- In production
- Bound with a retry budget (e.g. ≤10% extra load) and only on retryable status/gRPC codes.
Exponential backoff — widen the gap each attempt
- What
- Wait 100ms, 200ms, 400ms… doubling between retries, up to a cap.
- Problem it solves
- Gives an overloaded dependency (case f) time to recover instead of being hammered on a fixed interval.
- Tradeoffs
- Adds latency to the eventual success; needs a max delay + max attempts.
- Architecture impact
- Standard in AWS SDKs, gRPC retry policy, and every mature client.
Jitter — randomize the backoff
- What
- Add randomness to each backoff interval (e.g. full jitter:
sleep = rand(0, base·2^n)). - Problem it solves
- Thousands of clients that failed together will otherwise retry in sync — a thundering herd that re-DDoSes the recovering service. Jitter spreads them out.
- When NOT to
- Never omit it at scale. Backoff without jitter just synchronizes the herd at wider intervals.
- In production
- AWS's canonical "backoff and jitter" guidance; full jitter is the usual default.
Making retries safe — idempotency & duplicates
Idempotency — same request, same single effect
- What
- An operation you can apply N times with the same result as applying it once. GET/PUT/DELETE are naturally idempotent; POST "charge card" is not — until you make it so.
- Problem it solves
- Makes retries safe under partial failure (cases c–e). Without it, at-least-once delivery double-charges.
- When to use
- Every mutating operation reachable by a retry — payments, order creation, message handlers.
- Tradeoffs
- Requires storing seen keys (TTL'd in Redis/DynamoDB) and a dedup check on the hot path — extra state + a read per write.
- Architecture impact
- The
Idempotency-Keyheader pattern (Stripe-style) is the industry standard; it's what lets "exactly-once" feel real.
Duplicate requests — the thing idempotency defends against
- What
- The same logical request arriving more than once: client retries, load-balancer replays, at-least-once queues (SQS, Kafka) redelivering.
- Problem it solves
- Framing: duplicates are inevitable, not a bug to eliminate. You design to absorb them.
- In production
- SQS standard queues, Kafka consumers, and webhook deliveries all redeliver. Dedup by idempotency key or a natural unique constraint in the DB.
- Architecture impact
- Consumers must be idempotent by contract — "process exactly the effect once" even when the message arrives twice.
Self-defense — stop failure from spreading
Circuit breakers — stop calling a dead dependency
- What
- A stateful wrapper (closed → open → half-open) that trips after a failure threshold and fails fast without making the call.
- Problem it solves
- Stops you from wasting resources on a dependency that's down (case b), and gives it room to recover instead of being hammered.
- Tradeoffs
- Tuning thresholds is fiddly; an over-eager breaker causes false outages. Needs a fallback for "open" state.
- In production
- Istio/Envoy outlier detection, resilience libraries, or
sony/gobreakerin Go.
Bulkheads — isolate resource pools
- What
- Partition resources (connection pools, goroutine limits, thread pools) per dependency so one can't consume everything.
- Problem it solves
- A slow Payment (case a) can't starve the goroutines/connections needed for unrelated, healthy work.
- Tradeoffs
- Lower peak utilization — you reserve capacity you might not always use.
- Architecture impact
- Ship-hull metaphor: one flooded compartment doesn't sink the vessel. Often a bounded worker pool or a semaphore per dependency in Go.
Backpressure — signal "slow down"
- What
- A downstream at capacity pushes resistance upstream — bounded queues that block/reject, flow control, 429s.
- Problem it solves
- Prevents unbounded queue growth and memory blowup when producers outpace consumers (case f).
- When NOT to
- Don't fake it with an unbounded in-memory buffer — that just moves the OOM.
- Architecture impact
- Go channels are bounded buffers; a full channel is backpressure. Kafka consumer lag is backpressure made visible.
Load shedding — reject early to survive
- What
- Deliberately dropping/rejecting excess requests (429/503) when overloaded, ideally by priority.
- Problem it solves
- A service that accepts everything under overload serves everyone slowly and then dies. Shedding keeps the accepted subset healthy.
- Tradeoffs
- Some users get errors now — a deliberate, better outcome than total collapse.
- In production
- Envoy/Istio rate limits, admission control, concurrency limiters. Shed cheap requests before expensive ones.
Graceful degradation — reduced service beats none
- What
- Falling back to a lesser-but-working behavior when a dependency fails — stale cache, default, or deferring the work.
- Problem it solves
- Keeps the core journey alive when a non-critical dependency (recommendations, ratings) is down.
- When NOT to
- Never "degrade" correctness on money or auth — fail closed there.
- Architecture impact
- Drives the split between critical-path and best-effort dependencies; e.g. accept the order, capture payment async.
Cascading & dependency failures — the domino
- What
- One component's failure overloads or blocks its callers, which fail and overload their callers — an outage that climbs the graph.
- Problem it solves
- Framing: the goal of timeouts, breakers, bulkheads, and shedding is collectively to contain the blast radius.
- In production
- Nearly every large-scale outage is a cascade — often triggered by latency + retries, not a hard crash.
- Architecture impact
- Map your dependency graph; know which are critical vs degradable; test failure with chaos/fault injection.
How the failure spreads (and how you stop it)
Payment p99 latency & error rate climb; goroutine count and connection-pool saturation rise on Order Service.
Order Service latency tracks Payment's; requests block, threads/goroutines pile up, health checks wobble.
Retries multiply load on Payment; Order Service exhausts resources and starts failing its own callers — the cascade climbs.
Timeout + deadline fail fast; circuit breaker opens; bulkhead isolates the pool; Payment sheds load with 429s; backoff+jitter thins the herd.
Breaker half-opens to probe; Payment drains its queue; idempotency keys reconcile the double-charges from the storm.
Delivery guarantees — and the "exactly-once" illusion
| Guarantee | Meaning | Failure mode | You need | Real systems |
|---|---|---|---|---|
| At-least-once | Message delivered ≥1 time; retried until acked. | Duplicates (case e) on retry/redelivery. | Idempotent consumers + dedup keys. | SQS standard, Kafka (default), most queues. |
| At-most-once | Delivered 0 or 1 time; never retried. | Loss — a dropped message is gone. | Tolerance for loss, or a reconciliation sweep. | Fire-and-forget UDP-style, metrics/telemetry. |
| "Exactly-once" | The illusion: at-least-once delivery + idempotent processing = each effect applied once. | Not a transport guarantee — the network can't promise it. Fails if processing isn't idempotent. | Idempotency key + dedup store (the effect is once, delivery still isn't). | Stripe idempotency keys; Kafka EOS = idempotent producer + transactions within Kafka only. |
Consistency & the CAP theorem
| Model | Read guarantee | Cost | When to choose | Real systems |
|---|---|---|---|---|
| Strong | Every read sees the latest committed write (linearizable). | Higher latency; coordination; reduced availability under partition. | Money, inventory, auth, uniqueness — correctness over availability. | RDS/Postgres (single-writer), DynamoDB strong reads, etcd/Raft. |
| Eventual | Reads may be stale; replicas converge eventually. | Anomalies (stale/out-of-order reads) the app must tolerate. | Feeds, catalogs, counts, caches — availability + scale over freshness. | DynamoDB default reads, S3 (now read-after-write), DNS, CDN caches. |
Check yourself
Order Service calls Payment, times out (but Payment actually charged the card), and retries the request. What's the correct fix so the retry doesn't double-charge?
Synchronous vs Asynchronous Communication
Choosing between a request that blocks for an answer and a message that fires and forgets is the single decision that most shapes coupling, resilience, and blast radius in a distributed system.
The core question: does the caller need the answer now?
Synchronous means the caller holds a connection open and blocks until a response returns — REST, gRPC, GraphQL over HTTP. Asynchronous means the caller hands a message to a broker and moves on — SQS, SNS, Kafka, EventBridge. The words describe the caller's relationship to the result, not the transport. A goroutine calling a slow HTTP endpoint is still synchronous coupling even though your own code didn't block; the two services are temporally coupled because the request fails if the callee is down.
When synchronous
- The caller cannot proceed without the result (auth check, price lookup, inventory read).
- A human is waiting on the other end of the request.
- You need read-your-writes / strong ordering guarantees inline.
- Low fan-out, low latency budget, simple request/reply semantics.
- Avoid when the callee is slow, flaky, or when one failure should not fail the whole chain.
When asynchronous
- Work can happen later — emails, receipts, indexing, analytics, ETL.
- You need to absorb spikes (buffer) or decouple producer/consumer scaling.
- Multiple independent consumers react to one event (fan-out).
- You want the caller to survive the callee being down.
- Avoid when the caller genuinely needs the result to continue, or when eventual consistency confuses the UX.
Two shapes of the same order flow
Synchronous — the caller blocks on the whole chain
One slow or failed hop fails the entire request. End-to-end latency is the sum of every hop. Simple to reason about, strong consistency, but tightly coupled and fragile under partial failure.
Asynchronous — the order is durable the moment it's enqueued
Order Service returns to the client immediately after the write to the queue. Payment is processed independently; if the worker is down, messages wait durably. Decoupled and resilient, at the cost of eventual consistency and harder end-to-end tracing.
Tradeoffs at a glance
| Dimension | Synchronous (REST/gRPC) | Asynchronous (SQS/SNS/Kafka) |
|---|---|---|
| Latency | Sum of every hop; caller waits | Enqueue is fast; total work completes later |
| Coupling | Temporal + spatial both sides up together | Decoupled via broker |
| Reliability | Callee down = request fails | Broker buffers; survives consumer downtime |
| Scalability | Backpressure propagates upstream | Queue absorbs spikes; scale consumers on depth |
| Failure handling | Retries inline; risk of cascading failure | Retries + DLQ; isolated blast radius |
| UX | Immediate confirmed result | "Accepted" now, done later (eventual) |
| Operational complexity | Lower — no broker to run | Higher — broker, DLQs, idempotency, ordering |
| Cost | Idle connections, over-provision for peak | Broker + storage cost; smoother compute utilization |
Decision matrix — pick the mechanism per requirement
| Requirement | Recommended | Why |
|---|---|---|
| Browser → API, JSON, public/partner integration | REST | Ubiquitous, cacheable, human-debuggable |
| Low-latency internal service-to-service, typed contract | gRPC | HTTP/2 multiplexing, protobuf, streaming, codegen |
| Decouple + buffer work for a single consumer group | SQS | Simple durable queue, at-least-once, DLQ, autoscale on depth |
| One event, many independent reactions (fan-out) | SNS | Pub/sub push to N subscribers (SQS, Lambda, HTTP) |
| Event replay, ordering, stream processing, audit log | Kafka | Durable ordered log, consumer offsets, replay, high throughput |
| Inventory/price read the caller must have inline | REST / gRPC | Result is required to proceed — synchronous is correct |
| Order accepted, payment/email/index happen later | SQS + SNS | Return fast, process durably, fan out to workers |
Async backbones — queue vs pub/sub vs log
SQS — competing consumers on a durable queue
- Model
- A message is delivered to one consumer in the group, then deleted after ack. Work is distributed, not broadcast.
- Delivery
- At-least-once (Standard) → design idempotent consumers. FIFO queues add exactly-once processing + ordering per
MessageGroupIdat lower throughput. - Scaling
- Add consumers to drain faster. Autoscale workers on
ApproximateNumberOfMessagesVisible. Visibility timeout + DLQ (maxReceiveCount) handle poison messages. - Use when
- You need to buffer and load-level work for a single logical consumer — payment processing, image resize, job queues.
SNS — broadcast one message to many subscribers
- Model
- Publish once to a topic; every subscriber gets a copy — SQS queues, Lambda, HTTP endpoints, email. Push, not poll.
- Delivery
- At-least-once, no ordering (Standard); FIFO topics exist. No retention — a subscriber down when the message fires may miss it unless fronted by an SQS queue.
- Pattern
- SNS → SQS fan-out is the workhorse: SNS broadcasts, each service owns a durable SQS queue so it can be down without losing events.
- Use when
- One event must trigger multiple independent reactions — OrderPlaced → billing, inventory, notifications, analytics.
Kafka — an append-only, replayable log
- Model
- Messages are appended to partitioned topics and retained. Consumers track their own offset; reading does not delete. Multiple consumer groups read the same log independently.
- Ordering
- Strict order within a partition. Partition by key (e.g.
orderId) to keep related events ordered while scaling horizontally. - Superpower
- Replay — reset offset to reprocess history, rebuild a projection, or feed a new consumer. Foundation for event sourcing & stream processing (Flink/Kafka Streams).
- Use when
- You need ordering, replay, high sustained throughput, an audit trail, or multiple teams consuming the same event stream.
Check yourself
After a customer checks out, you must send a payment-confirmation email. Should the checkout request call the email service synchronously or publish an event asynchronously?
Go Concurrency Model
Go's concurrency isn't threads-with-nicer-syntax — it's a runtime scheduler multiplexing cheap goroutines onto OS threads, with channels as the primitive for passing ownership instead of sharing memory.
The mental model: CSP, not shared memory
Coming from Python's GIL or Node's single event loop, the shift is that Go gives you true parallelism (multiple goroutines running on multiple cores) and cheap concurrency (spawn 100k goroutines, ~2KB stack each, grown on demand). The guiding proverb: "Don't communicate by sharing memory; share memory by communicating." A channel transfers ownership of data between goroutines, so at any instant one goroutine owns it — no lock needed. Mutexes still exist for protecting shared state, but reach for channels first for coordination and mutexes for protecting a small piece of state.
Primitives
- goroutine —
go f(); scheduled by the runtime (GOMAXPROCS OS threads). - channel — typed conduit;
ch <- v/<-ch. Unbuffered = synchronous handoff; buffered = bounded queue. - select — wait on multiple channel ops; enables timeouts, cancellation, fan-in.
- context — carries deadlines & cancellation across API/goroutine boundaries.
Composition patterns
- Worker pool — fixed N goroutines drain a jobs channel; bounds concurrency.
- Fan-out — dispatch one input to many workers in parallel.
- Fan-in — merge many result channels into one.
- Pipeline — stages connected by channels, each a fan-out/fan-in.
Coordination primitives
select + context cancellation
select {
case res := <-work:
return res, nil
case <-ctx.Done():
return nil, ctx.Err() // deadline / cancel
}
context propagates a cancel signal down the call tree. When the client disconnects or a deadline fires, every goroutine watching ctx.Done() unwinds — the mechanism behind timeout propagation across a request.
Worker pool (bounded concurrency)
jobs := make(chan Job, 100)
for i := 0; i < 8; i++ { // 8 workers
go func() {
for j := range jobs { process(j) }
}()
}
Unbounded go-per-request will exhaust memory and thrash the DB connection pool. A pool caps in-flight work — the concurrency equivalent of a connection limit.
Failure modes — what bites you in production
Race conditions — two goroutines touch the same memory, one writes
- What
- Unsynchronized concurrent access to shared state where at least one is a write; result depends on scheduling. Manifests as corrupted counters, torn structs, map panics.
- Detection
- Run tests & staging with the built-in race detector:
go test -race,go run -race. It instruments memory access and reports the conflicting stacks. ~5–10× slower — CI/staging, not prod. - Mitigation
- Confine state to one goroutine and pass via channels; or guard with
sync.Mutex/RWMutex; or usesync/atomicfor counters. Concurrent map writes panic — usesync.Mapor a mutex. - In production
- Races are non-deterministic and load-dependent — they surface at scale, not in dev. The detector in CI is your only reliable line of defense.
Deadlocks — everyone is waiting, no one is proceeding
- What
- Goroutines block forever: send on a channel with no receiver, receive with no sender, or lock ordering cycles (A holds L1 wants L2; B holds L2 wants L1).
- Detection
- Runtime detects total deadlock (all goroutines blocked) and panics with
fatal error: all goroutines are asleep - deadlock!. Partial deadlocks don't panic — spot them via a goroutine dump (SIGQUIT,pprof /debug/pprof/goroutine) showing rising blocked counts. - Mitigation
- Always give blocking ops an escape via
select { case <-ctx.Done() }. Acquire multiple locks in a consistent global order. Prefer buffered channels or timeouts over unbounded blocking sends. - Architecture impact
- A partially deadlocked service passes liveness probes while silently wedging request handlers — add a timeout on the work, not just the HTTP handler.
Goroutine leaks — spawned goroutines that never exit
- What
- A goroutine blocks forever on a channel that's never written/read, or loops without a cancellation path. Each leak pins its stack + captured memory.
- Detection
- Watch
go_goroutines(Prometheus /runtime.NumGoroutine()) trending up and never recovering.pprofgoroutine profile shows thousands parked on the same line. In tests,goleak(uber-go/goleak) fails on leaks. - Mitigation
- Every goroutine needs a guaranteed exit: pass
ctxand select onctx.Done(); ensure channels get closed; never start a goroutine you can't stop. The classic leak: firing a request goroutine, thenreturning on timeout while it blocks forever trying to send its result to an unbuffered channel — give that channel a buffer of 1. - In production
- Slow OOM: memory climbs over hours/days until the pod hits its limit and the OOMKiller restarts it — looks like a memory leak but it's leaked goroutines.
Backpressure — producers outrunning consumers
- What
- Work arrives faster than it's processed. Without a bound, queues grow until memory is exhausted; latency balloons as items wait.
- Detection
- Rising channel/queue depth, growing p99 latency with flat throughput, climbing heap. For Kafka/SQS: consumer lag /
ApproximateNumberOfMessagesVisible. - Mitigation
- Bounded buffered channels + worker pool create natural backpressure — a full channel blocks the producer, pushing the signal upstream. At the edge: load-shed (return 429), rate-limit, or drop low-priority work. Never use an unbounded queue as a shock absorber.
- Architecture impact
- Backpressure must be end-to-end: a bounded internal pool is useless if the HTTP server accepts unlimited concurrent requests. Cap acceptance, not just processing.
Worked example: one request, three service calls
A checkout handler must call User, Payment, and Fraud services. They're independent reads — a perfect fan-out candidate.
u, err := userSvc.Get(ctx, id)
if err != nil { return err }
p, err := paySvc.Get(ctx, id)
if err != nil { return err }
f, err := fraudSvc.Check(ctx, id)
if err != nil { return err }
// total latency = tUser + tPay + tFraud
Simple and easy to read, but latency is the sum of all three. If each is 100ms, the user waits 300ms — for calls that had no reason to be serialized.
g, ctx := errgroup.WithContext(ctx)
var u User; var p Payment; var f Fraud
g.Go(func() error { var e error; u, e = userSvc.Get(ctx, id); return e })
g.Go(func() error { var e error; p, e = paySvc.Get(ctx, id); return e })
g.Go(func() error { var e error; f, e = fraudSvc.Check(ctx, id); return e })
if err := g.Wait(); err != nil { return err } // first error cancels the rest
// total latency ≈ max(tUser, tPay, tFraud)
errgroup runs all three in parallel; the first error cancels ctx, unwinding the others (timeout propagation for free). Latency drops to the slowest call — ~100ms instead of 300ms.
| Dimension | Sequential | Concurrent (fan-out) |
|---|---|---|
| Latency | Sum of all calls (~300ms) | max of calls (~100ms) |
| Throughput | Lower — each request holds resources longer | Higher — requests clear faster |
| CPU | One goroutine, one core at a time | Briefly uses multiple cores; more scheduling |
| Memory | Minimal — single stack | N goroutine stacks in flight per request |
| Failure handling | Fail fast on first error, rest never run | First error cancels siblings via ctx |
| Timeout propagation | Deadline shared but checked serially | Shared ctx cancels all in-flight calls at once |
Fan-out / fan-in shape
Fan-out dispatches the work across goroutines; fan-in (g.Wait() / merged channel) collects results once all complete. The same shape scales a pipeline stage: many workers, one collector.
resources.requests.cpu (bursts across cores) and higher requests.memory (N stacks + buffered results per request) than the sequential version, or it throttles / OOMKills under load. Sizing in turn shapes HPA behavior: CPU-based autoscaling reacts well to CPU-bound fan-out, but for I/O-bound handlers (goroutines parked on network) CPU stays low while latency climbs — scale on RPS or a custom/queue-depth metric instead. And it shapes scaling characteristics: bounded worker pools give predictable per-pod capacity (easy to model replicas = target RPS ÷ per-pod RPS), while unbounded go-per-request makes a pod's resource use a function of traffic — so it OOMs before HPA can react. Bound your concurrency and your autoscaler becomes predictable.Check yourself
A handler starts a goroutine to call a slow service, then returns early when ctx times out. Over hours, go_goroutines climbs steadily and the pod eventually OOMKills. What's the most likely cause and fix?
resultCh <- v on an unbuffered channel, and blocks forever — its stack and captured memory never freed. Rising go_goroutines that never recovers is the signature; a pprof goroutine profile shows thousands parked on the same send line. Fix: buffer the channel (make(chan T, 1)) so the send always completes, or have the worker select on ctx.Done() so it abandons the send when the request is gone. It looks like a memory leak but it's leaked goroutines.Production Go
A binary that compiles is not a service that survives a rolling deploy — production-readiness is about how your process behaves under SIGTERM, resource limits, and the runtime's collision with the Linux CFS scheduler.
What "production-ready" actually means
Lifecycle-correct
Starts fast, reports readiness honestly, and drains in-flight work on SIGTERM before the pod dies. No dropped requests during a deploy.
Runtime-aware
Respects its cgroup: GOMAXPROCS matches the CFS quota and GOMEMLIMIT sits under the memory limit so the GC backs off before the OOM killer fires.
Observable & configurable
Structured logs, /metrics, pprof, and 12-factor config from env/secrets — nothing baked into the image.
Graceful shutdown & the probe triad
The shutdown sequence
When K8s deletes a pod it (1) removes it from Service endpoints and (2) sends SIGTERM, then waits terminationGracePeriodSeconds (default 30s) before SIGKILL. Your job is to catch the signal, stop accepting new work, and finish in-flight requests within that window.
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGTERM, syscall.SIGINT)
defer stop()
<-ctx.Done() // SIGTERM arrived
shutCtx, cancel := context.WithTimeout(
context.Background(), 25*time.Second)
defer cancel()
srv.Shutdown(shutCtx) // stop listener, drain conns
http.Server.Shutdown stops accepting new connections and blocks until active requests finish or shutCtx expires. Propagate that same context into DB calls and downstream RPCs so slow work is cancelled, not orphaned.
Readiness vs liveness vs startup
Three probes, three completely different failure semantics. Confusing them is the most common self-inflicted outage.
| Probe | Question | Failure action |
|---|---|---|
| readiness | Can I serve traffic right now? | Pod removed from Service endpoints. No restart. Traffic reroutes. |
| liveness | Am I wedged/deadlocked? | Kubelet kills & restarts the container. |
| startup | Have I finished booting? | Gates the other two; on failure restarts, but suppresses liveness until it passes. |
- Liveness that checks a dependency (DB/cache): a downstream blip restarts every replica → cascading outage.
- Liveness = "is my event loop alive". Readiness = "are my dependencies reachable". Startup = "slow init / migrations done".
Concept accordions
Context cancellation on shutdown — solves orphaned work
- What
- A single context.Context threaded from the signal handler through every request, DB query, and RPC.
- Problem it solves
- Without it, Shutdown waits on a 5-minute query while the grace period expires and SIGKILL truncates it mid-transaction.
- Tradeoffs
- Every blocking call must accept a ctx and honor it; a single context.Background() deep in a driver defeats the whole chain.
- In production
- Cancelled ctx unwinds goroutines and returns context.Canceled; log it as info, not error, during shutdown.
Configuration & secrets — solves "image per environment"
- What
- 12-factor config: env vars for tunables, mounted secrets for credentials. Same image, every environment.
- When to use
- Env vars for non-sensitive knobs (log level, pool size, feature flags). K8s Secrets (mounted files or env) / AWS Secrets Manager / SSM Parameter Store for credentials, DB passwords, API keys.
- When NOT to
- Never bake secrets into the image or a committed .env. Prefer mounted-file secrets over env-var secrets — env leaks into crash dumps and /proc.
- Architecture impact
- Secrets Manager/SSM adds a fetch-at-boot dependency (cache + refresh via IRSA on EKS); K8s Secrets are etcd-backed and need encryption-at-rest + RBAC.
Connection reuse — pooling, HTTP keep-alive, gRPC keepalive
- What
- Reusing established connections instead of paying TLS+TCP handshake per call.
- Problem it solves
- Per-request dials exhaust ephemeral ports and add tail latency. DB pools cap concurrent connections; HTTP keep-alive reuses TCP; gRPC multiplexes many RPCs over one HTTP/2 conn.
- Tradeoffs
- Default http.Transport has MaxIdleConnsPerHost=2 — a high-fanout client silently opens/closes conns; raise it. gRPC keepalive pings detect dead conns but too-aggressive pings get you GOAWAY ENHANCE_YOUR_CALM.
- In production
- Size DB pool to (replicas × pool) ≤ DB max_connections. A 50-replica service with pool=20 = 1000 conns — RDS will refuse.
Timeouts & TLS — solves unbounded blocking
- What
- Explicit deadlines at every layer: server read/write/idle, client overall, and per-call RPC deadlines.
- Problem it solves
- Go's default http.Server has no timeouts — a slowloris or hung upstream ties up goroutines forever. Set ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout.
- When to use
- Per-call deadlines via context.WithTimeout propagate across gRPC as the deadline header, so downstream services stop working when the caller has already given up.
- Architecture impact
- TLS terminated at ingress/mesh (Istio mTLS) offloads handshakes; in-app TLS gives end-to-end encryption but you own cert rotation.
Resource limits: where Go and the Linux scheduler collide
CPU — CFS quota vs GOMAXPROCS
A container CPU limit is enforced by the kernel's CFS quota: e.g. limits.cpu: "2" = 200ms of CPU per 100ms period. But Go reads runtime.NumCPU() from the node — on a 64-core node GOMAXPROCS defaults to 64.
Result: Go spins up 64 OS threads racing to burn a 2-core quota. The quota is exhausted in the first few ms of each period and every goroutine is throttled for the rest — huge p99 latency spikes and wasted context-switching.
- Import go.uber.org/automaxprocs — it reads the cgroup quota and sets GOMAXPROCS to match.
- Go 1.25+ makes the runtime cgroup-aware natively; automaxprocs remains the safe default on older versions.
Memory — OOMKill vs GOMEMLIMIT
Go's GC targets a heap that grows to ~2× live set (GOGC=100). It has no idea about the container memory limit — so a burst pushes RSS past limits.memory and the kernel OOMKills the process (exit 137), mid-request, no graceful shutdown.
- Set GOMEMLIMIT to ~90% of the container limit (soft limit): the GC runs harder as you approach it, trading CPU for staying alive.
- Tuning GOGC alone can't guarantee a ceiling — only GOMEMLIMIT does.
Profiling & health endpoints
Operational flow: Application → Docker → Kubernetes → EKS
Go Application
Owns lifecycle correctness: catches SIGTERM, drains, honors cgroup limits via automaxprocs + GOMEMLIMIT, exposes pprof/metrics/health. Everything below is powerless if this layer ignores signals.
Docker
Multi-stage build → tiny static image on distroless/scratch. No libc, no shell, non-root UID. Smaller attack surface and fast pulls, but no exec debugging — you rely on pprof/logs.
Kubernetes
Enforces resource limits through cgroups (the CFS-quota source), wires the three probes, delivers SIGTERM and the grace period. This is where the GOMAXPROCS trap bites.
EKS
Managed control plane + node autoscaling. IRSA maps a K8s ServiceAccount to an IAM role so pods pull from Secrets Manager/SSM with no static keys. ALB/NLB ingress, CloudWatch + Container Insights for node-level throttling metrics.
Check yourself
Your service's dependency (Postgres) goes down for 90 seconds. Which probe should reflect this, and what happens if you wire it to the wrong one?
Observability for Go Services
Monitoring tells you that something broke; observability lets you ask why — and the difference is whether you can decompose a 2.7-second request across the four hops it touched without shipping a new build.
The three pillars — and the question each answers
| Signal | Answers | Cardinality | Go tooling |
|---|---|---|---|
| Logs | "What exactly happened in this one request?" — discrete events, full detail. | Unbounded (cheap-ish per line, expensive to index) | log/slog (structured JSON) |
| Metrics | "What is the aggregate health — rate, errors, latency over time?" | Bounded — keep it low | prometheus/client_golang |
| Traces | "Where did this request spend its time across services?" | Sampled (per-request spans) | OpenTelemetry SDK |
Structured logging & correlation
Why structured
JSON key/value lines instead of free text: greppable becomes queryable. Every log line carries the trace_id so a log search pivots straight into the matching trace.
slog.InfoContext(ctx, "order placed",
"trace_id", traceID,
"order_id", id,
"amount_cents", 4200)
Correlation IDs vs trace/span IDs
- Trace ID — one per end-to-end request, shared by every span across every service.
- Span ID — one per unit of work (a single hop/DB call); spans form a tree under the trace ID.
- Correlation ID — a business-level ID (order, session) you attach to logs/metrics for cross-request grouping; complements, doesn't replace, the trace ID.
Metrics: RED, the four golden signals, and SLOs
RED (per service)
- Rate — requests/sec
- Errors — failed requests/sec
- Duration — latency distribution (histogram → p50/p95/p99)
Request-scoped; ideal for user-facing services.
Four golden signals (SRE)
- Latency · Traffic · Errors — overlap RED
- Saturation — how full is the resource (CPU quota, pool, queue depth)
Saturation is the leading indicator RED omits — it predicts the cliff before latency spikes.
Prometheus: pull model & the cardinality bomb
Pull model
Prometheus scrapes each pod's /metrics endpoint on an interval (via K8s service discovery) — the app doesn't push. Pull means Prometheus controls load, and a missing scrape target is itself a signal (the up metric goes 0).
Cardinality warning
Each unique combination of label values is a separate stored time series. Labels with unbounded values — user_id, order_id, raw URL paths, request IDs — multiply series into the millions and OOM the Prometheus server.
- http_requests_total{path="/user/8842/orders/5510"}
- http_requests_total{route="/user/:id/orders/:oid", status="200"}
Keep labels to bounded dimensions (route template, method, status class). Put the high-cardinality identifiers in traces and logs, never in metric labels.
OpenTelemetry & distributed tracing
OpenTelemetry is the vendor-neutral SDK/protocol for emitting traces (and metrics/logs). Context propagation carries the trace across process boundaries via the W3C traceparent header — 00-{trace-id}-{span-id}-{flags} — injected on outbound HTTP/gRPC and extracted on the receiver, so every service continues the same trace instead of starting its own.
Worked example — tracing one request end to end
One trace ID (4bf9…) spans all four hops; each hop is a child span with its own duration. The propagated traceparent is what lets the backend reassemble them into a single waterfall.
Reveal: why did this request take 2.7 seconds?
The trace waterfall decomposes the 2.7s across spans — you don't guess, you read it:
92% of the latency is a single DB span — and it's not query execution, it's time blocked waiting to acquire a row lock (another long transaction held it). Metrics alone would only tell you "p99 latency is up on API Service" — pointing you at the wrong service. The trace localizes it to the DB hop; the DB span's attributes/logs (lock wait event, blocking PID) name the culprit.
How the pillars combined: RED Duration metric fired the SLO burn-rate alert → the trace showed which span owned the 2.5s → the span's logs (same trace_id) revealed the lock contention. Fix: shorten the competing transaction / add an index so it releases the lock sooner — not scaling API Service, which the metric-only story would have wrongly suggested.
Check yourself
Which of these is the dangerous Prometheus label choice that will blow up your time-series count?
Cloud-Native Go
Go didn't get lucky in the cloud — its runtime shape (static binaries, cheap goroutines, fast builds, one deployable artifact) matches exactly what infrastructure software needs, which is why the control plane you run every day is written in it.
Why Go dominates the cloud control plane
The pattern is consistent: long-lived daemons that watch state, talk to APIs over the network, and must ship as a single artifact into a container. Go's static binary (no interpreter, no JVM, no shared-lib hunt), its goroutine model for fan-out to thousands of watches/connections, and sub-second builds for a tight operator dev loop make it the default. The result is the ecosystem below — nearly the entire CNCF landscape.
Kubernetes
The container orchestrator itself — API server, scheduler, controllers, kubelet.
Why Go: thousands of concurrent watches and reconcile loops map naturally to goroutines; a single static kube-apiserver binary ships anywhere; strong stdlib HTTP/TLS for the API surface.
Docker
The engine and CLI that popularized containers.
Why Go: direct Linux syscall access (namespaces, cgroups) without C glue, plus one binary that runs on any host — no runtime to pre-install on the node.
containerd
The CRI runtime under Docker and Kubernetes; manages image pull, storage, and container lifecycle.
Why Go: gRPC-native service boundaries, low idle footprint for a per-node daemon, clean concurrency for many container lifecycles at once.
Terraform
Declarative infrastructure provisioning with a plugin/provider model.
Why Go: a single cross-compiled CLI for every OS/arch, and Go's plugin RPC lets hundreds of providers ship as independent binaries.
Prometheus
Pull-based metrics + time-series database at the center of cloud-native observability.
Why Go: concurrent scraping of thousands of targets via goroutines; GC-friendly enough for a high-throughput TSDB; one binary for the scraper + storage + query engine.
etcd
The consistent, distributed key-value store that is Kubernetes' source of truth (Raft-backed).
Why Go: Raft leader/follower coordination is a natural fit for goroutines + channels; gRPC API; predictable static deploy for a quorum member.
Helm
The package manager for Kubernetes — templated, versioned application releases.
Why Go: Go templates power the chart engine, and a single client binary talks straight to the API server with no server-side component (Helm 3).
What Go lets you build
Because the client libraries, codegen, and controller frameworks are all Go-first, the natural language for extending a cluster or building platform tooling is Go. Each card names the problem it solves — not just the artifact.
Kubernetes controllers
Problem: some desired state (replica count, cert rotation, DNS record) must be continuously enforced against drift. A controller watches and re-converges forever.
Operators
Problem: stateful software (a database, Kafka) needs domain-specific day-2 ops — backups, failover, version upgrades. An operator encodes that human runbook as a controller over a CRD.
Admission webhooks
Problem: you must enforce policy at write time. Validating webhooks reject non-compliant objects (no latest tags); mutating webhooks inject defaults/sidecars before persistence.
Custom controllers & CRDs
Problem: your own abstraction (a TenantEnvironment, a CertRequest) needs first-class API behavior. A CRD extends the API; a custom controller gives it lifecycle.
CLI tools
Problem: operators need ergonomic, single-binary tooling that runs on every laptop and CI runner. Cobra/urfave give you kubectl-grade CLIs with zero runtime deps.
Custom integrations
Problem: glue between your cluster and external systems (cloud APIs, ticketing, secrets stores) needs to run reliably as a long-lived service — Go's HTTP/gRPC clients and context cancellation fit this.
Platform-engineering tools
Problem: internal developer platforms (golden paths, self-service provisioning) need backend services that speak Kubernetes' API fluently — the client-go ecosystem is native Go.
Infrastructure agents / daemons
Problem: per-node work (log shipping, metric export, CNI, CSI) must run as a lean, always-on DaemonSet. Static binaries with a small idle footprint are ideal.
The centerpiece: the reconcile loop
Every controller above is the same shape underneath. It does not "handle events" the way a request handler does — it drives observed state toward desired state, forever. This is the single most important mental model in cloud-native Go.
The loop closes on itself: after updating, the controller keeps watching, and any change (a pod dies, a node drains, a spec edit) re-triggers Reconcile(). It never assumes its last action stuck.
How this maps to the Kubernetes architecture you already know
- API server = source of truth. Controllers never talk to each other directly — they read desired state and write status through the API server, which persists everything to etcd.
- etcd holds the authoritative object store; the API server is the only writer. Your reconcile reads a cached view, not etcd directly.
- Informers / watch. Instead of polling, controllers use a shared informer that maintains a local cache and a work queue, fed by the API server's watch stream. "Observe current state" is a cache lookup, not a live API call.
- controller-runtime (the sigs.k8s.io framework, and what Kubebuilder/Operator SDK generate) wires up managers, caches, and the work queue so you write only Reconcile(ctx, req).
- CRDs register your custom type with the API server so it gets stored, validated, and watched exactly like a built-in resource — that's what makes an operator possible.
- Requeue is first-class: return an error or a RequeueAfter and the item goes back on the rate-limited queue. Retries are the norm, not the exception.
Why do Kubernetes controllers reconcile toward the full desired state on every wake-up, instead of just applying the specific event that triggered them (e.g. "a pod was deleted → recreate one")?
Database & Storage Decisions
A datastore is not chosen from a feature checklist — it's chosen by working backward from the access pattern, so each scenario below walks the chain Requirements → Constraints → Candidates → Tradeoffs → Decision.
Scenario 1 — 500k writes/sec, known access patterns, single-digit-ms latency
High-volume, key-based, predictable
Think an IoT telemetry sink or a game leaderboard: enormous, steady write throughput, every query is by a known key, and the latency budget is a few milliseconds at p99.
Reveal recommended architecture
- Requirements
- 500k writes/sec sustained; single-digit-ms reads/writes; horizontal scale with no operator toil.
- Constraints
- Access patterns are known up front and never need ad-hoc joins or aggregations; queries are by partition/sort key only.
- Candidates
- DynamoDB, Cassandra, or a heavily-sharded Postgres. Postgres would need manual sharding to reach this write rate.
- Tradeoffs
- DynamoDB gives near-linear scale and managed ops, but you must model the table around queries first — no joins, expensive scans, and hot-partition risk if the key is skewed. Cost scales with provisioned/consumed capacity.
- Decision
- DynamoDB — the workload is exactly its sweet spot: known keys, extreme write scale, predictable latency, zero DB servers to run.
Scenario 2 — complex joins, transactions, strong relational integrity
Relational core-of-record
Think orders, payments, inventory: many entities related to each other, multi-row invariants that must hold atomically, and reporting queries that join across tables.
Reveal recommended architecture
- Requirements
- Multi-table joins, ACID transactions spanning rows, foreign-key integrity, ad-hoc analytical queries.
- Constraints
- Correctness > raw write throughput; access patterns evolve, so query flexibility matters more than pre-modeling.
- Candidates
- PostgreSQL, MySQL, or a distributed SQL engine (CockroachDB/Spanner) if you outgrow one node.
- Tradeoffs
- A single-primary Postgres has a vertical write ceiling and needs read replicas + careful failover for HA. In return you get joins, transactions, constraints, rich indexing, and SQL flexibility a KV store can't match.
- Decision
- PostgreSQL — when relationships and transactional integrity are the point, the relational model is the right tool; reach for distributed SQL only when you truly exceed one primary.
Scenario 3 — rapidly changing document structure, frontend-heavy, real-time listeners
Client-driven, evolving schema, live sync
Think a collaborative app or a mobile product where the client subscribes to data and the shape of documents shifts release to release.
Reveal recommended architecture
- Requirements
- Flexible/nested documents; push-based real-time updates to clients; SDKs that talk directly from the frontend with auth rules.
- Constraints
- Schema changes constantly; team wants to avoid running a backend for CRUD; offline/sync matters.
- Candidates
- Firestore, MongoDB (+ change streams), or a custom WebSocket layer over Postgres.
- Tradeoffs
- Firestore gives real-time listeners, offline sync, and security rules out of the box, but query power is limited (no joins, composite-index management), and cost is per-read/write/listener — chatty apps get expensive.
- Decision
- Firestore — the real-time listener + flexible-document + direct-from-client story is exactly what it optimizes for.
Scenario 4 — caching, very low latency, temporary state
Hot, ephemeral, sub-millisecond
Think a session store, rate-limiter counters, a leaderboard, or a cache in front of a slower system of record.
Reveal recommended architecture
- Requirements
- Sub-ms reads/writes; data is transient or reconstructable; simple key/value or small data-structure ops (counters, sets, sorted sets).
- Constraints
- Durability is secondary — losing the cache is survivable; primary store lives elsewhere.
- Candidates
- Redis, Memcached, or an in-process cache for single-node cases.
- Tradeoffs
- Redis is in-memory, so capacity is bounded by RAM and cost scales with it; persistence (RDB/AOF) is best-effort, not a system of record. In return: microsecond latency and rich primitives (TTL, INCR, sorted sets, pub/sub).
- Decision
- Redis — purpose-built for hot, temporary state and cache-aside patterns; never the durable source of truth.
Scenario 5 — object/blob storage, large files, durable cheap storage
Large immutable blobs, cheap and durable
Think images/video, backups, data-lake files, build artifacts, ML datasets — big objects fetched by key, not queried.
Reveal recommended architecture
- Requirements
- Store objects from KB to TB; extreme durability (11 nines); cheap at-rest cost; access by key, streamed or via presigned URL.
- Constraints
- No query engine needed; objects are largely immutable; access is infrequent-to-bursty, not transactional.
- Candidates
- S3 (or GCS/Azure Blob), possibly fronted by a CDN.
- Tradeoffs
- S3 is not a database: no transactions, no secondary indexes, higher per-request latency than a KV store, eventual listing semantics historically. In return: effectively unlimited capacity, the cheapest durable storage, tiering to Glacier, and direct browser upload/download.
- Decision
- S3 — for durable, cheap, large-object storage there's no relational or KV substitute; keep metadata/pointers in a real DB and the bytes in S3.
Decision summary
| Store | Data model | Consistency | Scaling model | Latency | Cost shape | Best-fit workload |
|---|---|---|---|---|---|---|
| PostgreSQL | Relational (tables, joins) | Strong, ACID transactions | Vertical + read replicas; shard/distributed SQL to go further | Low ms | Instance-hours + storage/IOPS | Relational core-of-record, joins, transactions |
| DynamoDB | Key-value / wide-column | Eventual by default, strong-read opt-in | Horizontal, near-linear, managed | Single-digit ms | Per-capacity (RCU/WCU) or on-demand + storage | Massive scale, known key-based access patterns |
| Firestore | Document (nested) | Strong per-document; limited multi-doc | Horizontal, fully managed | Low ms + real-time push | Per read/write/delete + listeners + storage | Flexible docs, real-time client sync, frontend-driven |
| Redis | In-memory KV + data structures | Strong on primary; async replica | Vertical (RAM) + cluster sharding | Sub-ms | Memory (RAM) hours — priciest per GB | Cache, sessions, counters, ephemeral hot state |
| S3 | Object / blob (key → bytes) | Strong read-after-write on objects | Effectively unlimited, managed | Tens of ms (per-object) | Cheapest per GB stored + per-request + egress | Large durable files, backups, data lakes, media |
You're building a shopping-cart / user-session store: high read-write rate, sub-ms latency, data is short-lived (expires when the session ends) and reconstructable if lost. Which datastore fits best?
Architecture Decision Records
The habit that separates a Solutions Architect from a coder: write down why, with the tradeoffs and failure modes, so the decision survives the person who made it.
The template
| Field | What it captures |
|---|---|
| Problem | The forcing function. What decision is required and why now. |
| Options | The 2–4 realistic candidates actually considered. |
| Decision | The one option chosen — stated plainly. |
| Why | The reasoning that made it win against the others. |
| Tradeoffs | What you knowingly gave up. Every real decision costs something. |
| Failure modes | How this choice breaks, and how you'll detect it. |
| Operational impact | What on-call, deploys, and runbooks now look like. |
| Security | Attack surface, identity, data exposure implications. |
| Scalability | Behavior at 10× / 100× the current load. |
| Cost | Infra + operational + cognitive cost shape. |
| Future implications | What this locks in, and the exit cost if you're wrong. |
Worked examples
Six decisions you will actually face, each argued to a concrete, defensible conclusion.
ADR-001 — REST vs gRPC
- Problem
- We're standing up a new service consumed by a browser SPA, mobile apps, and ~8 internal services. One protocol or two?
- Options
- (a) REST/JSON everywhere; (b) gRPC everywhere; (c) REST at the public edge, gRPC internally.
- Decision
- REST at the edge gRPC internally — option (c).
- Why
- Browsers can't speak gRPC without a proxy and third parties expect JSON, so the public contract must be REST. Internally, calls are hot-path, high-volume, and same-team — the enforced
.protoschema, codegen, and binary framing pay off in latency and correctness. - Tradeoffs
- Two serialization stacks to maintain; a translation boundary at the gateway; developers must know both. We accept that for the edge/internal impedance match.
- Failure modes
- Schema drift between the REST DTOs and proto messages at the gateway; mitigated by generating the edge types from the same source and contract tests.
- Operational impact
- gRPC needs HTTP/2-aware LB config and per-call deadlines; REST keeps its familiar curl/Postman debuggability at the edge.
- Future implications
- Adding a new internal service is cheap (add a proto). Exposing a new public surface is a deliberate gateway change — which is the boundary we want.
ADR-002 — PostgreSQL vs DynamoDB
- Problem
- Primary datastore for an Orders service: strong integrity across orders/line-items/payments, plus ad-hoc reporting.
- Options
- (a) PostgreSQL (RDS/Aurora); (b) DynamoDB.
- Decision
- PostgreSQL.
- Why
- The workload has multi-entity transactions, foreign-key integrity, and unpredictable query shapes (reporting/joins). That is exactly what a relational engine is for. DynamoDB shines when access patterns are known and fixed — ours aren't.
- Tradeoffs
- We give up DynamoDB's near-infinite, hands-off horizontal write scaling. We accept managing connection limits, vacuum, and read-replica routing.
- Failure modes
- Connection-pool exhaustion under fan-out (Postgres is connection-bound) — mitigated with PgBouncer and bounded pools; slow queries under lock — mitigated with query timeouts + pg_stat_statements.
- Scalability
- Vertical + read replicas to a point; if writes eventually exceed a single primary we shard by tenant or peel hot access patterns into DynamoDB — a later, deliberate migration, not a day-one tax.
- Cost
- Predictable instance cost vs DynamoDB's per-request + storage model; cheaper at steady moderate scale, and no capacity-planning of RCUs/WCUs.
ADR-003 — Synchronous vs Asynchronous processing
- Problem
- When an order is placed, we must charge payment, reserve inventory, and send a confirmation email. Do these happen inline in the request, or after?
- Options
- (a) All synchronous in the request; (b) all asynchronous via a queue; (c) hybrid — charge sync, fulfill async.
- Decision
- Hybrid — payment authorization is synchronous; inventory, email, analytics are asynchronous via SQS.
- Why
- The user must know immediately whether their card was accepted — that answer belongs in the request. Everything downstream tolerates seconds of delay and benefits from decoupling, retries, and buffering under spikes.
- Tradeoffs
- We accept eventual consistency for fulfillment and the operational weight of a queue + workers + a DLQ, in exchange for a fast, reliable checkout and independent scaling.
- Failure modes
- An event lost between sync and async legs → the outbox pattern (persist the event in the same DB transaction as the order, relay to SQS) prevents "charged but never fulfilled".
- Operational impact
- New signals to watch: queue depth, consumer lag, DLQ count. New runbook: replay the DLQ.
ADR-004 — Go vs Java for a new high-throughput service
- Problem
- New internal service: high RPS, heavy fan-out to other services, must autoscale cheaply on EKS.
- Options
- (a) Go; (b) Java/Spring Boot.
- Decision
- Go.
- Why
- The work is I/O-bound fan-out with no heavy domain logic. Go's goroutine-per-request model and small static binary give fast cold starts, low RAM per pod, and tight tail latencies — directly improving pod density and HPA responsiveness. Java's strengths (rich domain frameworks, mature ecosystem) aren't what this service needs.
- Tradeoffs
- We give up Spring's batteries-included DI/ORM/ecosystem and the larger Java hiring pool. We accept Go's verbose error handling and leaner libraries.
- Operational impact
- ~10–30 MB images vs hundreds of MB; sub-second startup vs JVM warmup; no GC-pause tuning war. Fewer knobs, faster recovery.
- Future implications
- If this service later grows a complex business domain, that's the signal to reassess — Go stays great for the plumbing, less so for sprawling domain models.
ADR-005 — Kubernetes (EKS) vs Serverless (Lambda)
- Problem
- Compute platform for a suite of always-on services with steady traffic and long-lived gRPC connections.
- Options
- (a) EKS; (b) Lambda + API Gateway.
- Decision
- EKS for the core services; Lambda reserved for spiky, event-driven glue.
- Why
- Steady traffic makes always-on pods cheaper than per-invocation billing, and long-lived gRPC/connection pooling fits poorly with Lambda's ephemeral, connection-hostile model. We already run K8s tooling, so the operational muscle exists.
- Tradeoffs
- We own cluster ops, node upgrades, and autoscaling config — real toil Lambda would have absorbed. We accept it for cost control and runtime flexibility.
- Scalability
- HPA + Cluster Autoscaler/Karpenter handle 10×; Lambda would scale-to-zero better for bursty workloads — which is exactly why the glue stays serverless.
- Cost
- Steady load: EKS wins. Bursty/idle-heavy load: Lambda wins. We split by workload shape rather than dogma.
ADR-006 — SQS vs Kafka
- Problem
- Transport for order-fulfillment work items and, separately, a stream of domain events other teams may consume later.
- Options
- (a) SQS everywhere; (b) Kafka everywhere; (c) SQS for work queues, Kafka for the event stream.
- Decision
- SQS for task queues; Kafka/MSK for the durable event log — option (c).
- Why
- Fulfillment is a competing-consumers work queue with per-message delete semantics and a native DLQ — SQS is purpose-built and near-zero-ops. The event stream needs retention, replay, and multiple independent consumer groups reading the same records — that's Kafka's log model, which SQS can't express.
- Tradeoffs
- Two systems to operate. Kafka brings real ops weight (partitions, consumer-group rebalancing, retention sizing); we take it only where the log semantics are actually required.
- Failure modes
- SQS: poison messages → DLQ + redrive. Kafka: consumer lag → lag alerting + partition/scaling review; rebalance storms → static membership.
- Future implications
- New consumers of existing events are free on Kafka (add a consumer group, replay from offset 0) — the reason the event log isn't just another queue.
When NOT to Use Go
Technology selection is about fit against requirements and constraints — not loyalty. Knowing where Go is the wrong default is what makes you trustworthy when you do recommend it.
Honest head-to-heads
Go wins: long-running network services, concurrency, single-binary deploys, predictable latency, CLIs.
Python wins: ML/AI, data science, notebooks, scientific computing, scripting, and anywhere the ecosystem (NumPy, pandas, PyTorch, scikit-learn) is the actual product. Development speed and library depth beat runtime speed here.
Verdict: if the value is in models, data wrangling, or glue, Python. If the value is in a fast, concurrent server, Go.
Go wins: lightweight high-throughput services, fast startup, low memory per pod, infra tooling, small teams.
Java wins: large, complex business domains; rich frameworks (Spring); mature ORM/transaction tooling; huge hiring pool; decades-hardened enterprise integrations; teams that value structure and convention.
Verdict: sprawling domain logic and big-org standardization → Java. Plumbing, agents, and edge services → Go.
Go wins: backend services where a compiled binary, real concurrency, and low memory matter.
TypeScript wins: anything in the browser (it's the only real option), full-stack teams sharing one language front-to-back, rich frontend/BFF ecosystems, and rapid product iteration on Node.
Verdict: UI or a JS-shared full stack → TypeScript. A standalone performant backend → Go.
Go wins: developer velocity, simplicity, fast compiles, a GC that removes a whole class of worry, and a deep cloud-native ecosystem.
Rust wins: no-GC / hard real-time constraints, maximum throughput with tight tail latency, systems programming (kernels, embedded, browser engines), and memory-safety guarantees enforced at compile time. The borrow checker's cost buys control Go deliberately hides.
Verdict: squeezing the last microsecond or going lower-level than a GC allows → Rust. Almost everything else in cloud services → Go, sooner and cheaper.
Domains where Go is often the wrong default
Model training & inference
The frameworks, GPU bindings, and research momentum live in Python (and C++/CUDA underneath). Go has no comparable ecosystem. Use Python.
Analysis & pipelines
Interactive exploration, dataframes, and viz are a Python/R strength. Go's numerical libraries are thin. Use Python/Spark.
Deep domain systems
Mature DI, ORM, BPM, and integration frameworks plus existing JVM investment favor Java/Kotlin. Use the JVM.
Browser UI
The browser runs JS/WASM. Go compiles to WASM but fights the grain; the component ecosystem is TS/React. Use TypeScript.
One-off glue & automation
For a 30-line throwaway, Python/Bash is faster to write and needs no build step. Go's ceremony isn't worth it. Use Python/Bash.
Manual control needed
Hard real-time, no-GC latency floors, SIMD, embedded, or kernel work need Rust/C/C++. Go's GC and runtime are in the way. Use Rust/C++.
Requirement → better-fit language
| Requirement / constraint | Better fit | Why not Go |
|---|---|---|
| Train/serve an ML model | Python | No competitive ML ecosystem or GPU tooling |
| Large, complex business domain + big team | Java / Kotlin | Thinner framework/ORM support; less "structure" |
| Interactive browser UI | TypeScript | Not a native browser language |
| No-GC, hard real-time, sub-µs tail latency | Rust / C++ | GC pauses & runtime remove that control |
| Quick throwaway automation script | Python / Bash | Compile/build ceremony not worth it |
| Data analysis / notebooks | Python / R | No dataframe/viz ecosystem |
| High-throughput concurrent network service | Go | — this is Go's sweet spot |
| Kubernetes controller / cloud tooling | Go | — native ecosystem, this is Go's home |
You're building a real-time ML inference pipeline: load a PyTorch model, run GPU inference, and serve predictions internally. What's the primary language?
System Design Scenarios
Ten architecture drills — read the requirements, weigh the constraints, commit to a choice, then check your reasoning against a defensible reference design.
1 · High-throughput payment API
A card-authorization endpoint fronting a PSP. 50k RPS at peak, spiky around retail events.
Requirements
- Authorize a card in <300ms p99 end-to-end.
- Every authorization attempt is durably recorded before the caller gets a response.
- Exactly-once effect per idempotency key — a retried request must not double-charge.
Constraints
- Downstream PSP is the hard latency floor (~120ms) and rate-limits us.
- PCI scope must stay tight — card data touches as few services as possible.
- Traffic is bursty; steady-state is ~8k RPS, peaks 6× for minutes.
What's the right shape for the hot path?
Reveal recommended architecture
Tradeoffs. DynamoDB gives single-digit-ms conditional writes and effortless burst scaling, but you pay in eventual-consistency reasoning and lack of ad-hoc queries — acceptable because the hot path only needs a keyed lookup. The circuit breaker trades some availability during a PSP brownout (fast failures) for protecting your fleet from thread exhaustion and turning a slow dependency into a fast, retryable error. Idempotency keys shift complexity onto the client (it must generate and reuse a key) in exchange for safe retries — the correct trade for money movement.
2 · Order processing system
E-commerce checkout that must coordinate inventory, payment, and fulfillment.
Requirements
- An order touches 3+ services and must not leave the system half-committed.
- Checkout must feel instant even when fulfillment is slow.
- A failed payment must release reserved inventory automatically.
Constraints
- No shared database — each domain owns its own store.
- A 2-phase-commit distributed transaction across services is off the table (locks, coordinator SPOF).
- Peak is Black-Friday-scale; independent scaling per stage is required.
How do you keep the order consistent across services?
Reveal recommended architecture
Tradeoffs. A Saga buys you loose coupling and independent scaling but forfeits atomicity — the system is only eventually consistent, so the UI must show "processing," and you must design every compensating action to be idempotent and safe out of order. Orchestration centralizes the logic (easy to see the whole flow, one place to add steps) at the cost of a service that knows about everyone; choreography is more decoupled but the business process is smeared across event handlers and painful to trace. Fulfillment goes behind SQS so a slow warehouse never blocks checkout.
3 · Real-time notification system
Push in-app + mobile notifications to millions of connected clients with low latency.
Requirements
- Deliver an event to a user's open sessions in <1s.
- Fan out one event to many subscribers (a match, a price change) efficiently.
- Hold hundreds of thousands of concurrent long-lived connections per fleet.
Constraints
- Clients disconnect and reconnect constantly (mobile networks).
- A user may be connected to any one of N gateway instances.
- At-least-once is fine; the client dedupes on message id.
How do you route an event to a user connected to an arbitrary gateway node?
Reveal recommended architecture
Tradeoffs. Stateful gateways are the price of low-latency push: you give up the "any request to any stateless pod" simplicity and must handle connection draining on deploy (graceful shutdown, client reconnect-with-backoff). Redis Pub/Sub is fire-and-forget — if a gateway is momentarily down the message is lost, which is why at-least-once with client-side dedupe (and a short replay buffer for critical events) is the right consistency level here rather than paying for a durable log like Kafka. Go's cheap goroutines are what make the connection density economical versus a thread-per-connection runtime.
4 · Large-scale event processing
Ingest 500k events/sec of clickstream + telemetry, transform, and land in analytics + real-time aggregates.
Requirements
- Absorb massive, uneven ingest without dropping events.
- Replay history when a consumer's logic changes or a bug is found.
- Multiple independent consumers read the same stream at their own pace.
Constraints
- Producers must never block on slow consumers.
- Ordering matters per entity (per user/device), not globally.
- Retention of days-to-weeks for replay and late-arriving joins.
What's the backbone?
Reveal recommended architecture
Tradeoffs. A log decouples producers from consumers and makes replay trivial, but you inherit partition mechanics: throughput scales with partition count, yet ordering is only guaranteed within a partition, so your key choice (userId/deviceId) is a permanent design decision — repartitioning later is painful. Retention costs storage. Consumers must be idempotent because the log guarantees at-least-once. Go stream processors give you tight control over batching and backpressure; a managed engine (Flink/Kinesis Analytics) trades that control for built-in windowing and exactly-once sinks if the stateful analytics justify it.
5 · Internal microservice platform
Dozens of Go services calling each other inside one EKS cluster; a platform team owns the substrate.
Requirements
- Low-latency, strongly-typed service-to-service calls.
- Uniform observability, retries, mTLS, and traffic policy without per-service code.
- New services onboard in a day with a golden path.
Constraints
- Teams must not hand-roll their own retry/TLS/metrics logic (drift, bugs).
- Calls are east-west and internal — no browser clients.
- Polyglot creeping in (a couple of Python/Java services).
What's the transport + cross-cutting-concerns strategy?
Reveal recommended architecture
Tradeoffs. gRPC + mesh gives you typed contracts and uniform, code-free reliability policy, but the mesh adds real operational weight: a sidecar per pod (memory + a proxy hop of latency), a control plane to run and upgrade, and a new failure surface to understand. gRPC's binary framing is great east-west but not browser-native (irrelevant here). The payoff is that reliability and security become platform properties, not per-team folklore — the right trade once you have dozens of services and a dedicated platform team. Below ~10 services, the mesh tax often isn't worth it yet.
6 · Kubernetes platform controller
Automate a company-internal resource (e.g. a "TenantDatabase") so teams self-serve via kubectl.
Requirements
- Teams declare desired state as a Kubernetes object; the platform makes it real.
- Continuously reconcile — drift, deletions, and partial failures self-heal.
- Integrate natively with RBAC, events, and the K8s API.
Constraints
- External systems are involved (provision an RDS instance, secrets, DNS) — actions can fail midway.
- Reconciliation must be idempotent and safe to run thousands of times.
- Must feel like a first-class K8s resource, not a bolt-on script.
How do you build it?
Reveal recommended architecture
Tradeoffs. The operator pattern gives you a truly declarative, self-healing, RBAC-integrated platform API — but reconcile loops are deceptively hard: every action must be idempotent, you must handle partial external failures by requeuing (not crashing), and you must use finalizers to clean up external resources on delete or you leak infrastructure. You're also now running and upgrading a stateful controller. The reward is that "correctness = converge to spec" replaces brittle one-shot scripts, and teams get a native kubectl experience. Overkill for a resource provisioned once and never changed.
7 · Public API vs internal API
One product surface needs both a partner-facing public API and dozens of internal service calls.
Requirements
- External partners integrate easily from any language and from browsers.
- Internal calls are high-volume and latency-sensitive.
- Stable, versioned public contracts; fast-moving internal ones.
Constraints
- Public consumers won't tolerate binary protocols or codegen requirements.
- Internal chatter shouldn't pay JSON serialization + HTTP/1.1 overhead.
- Auth, rate-limiting, and quotas needed at the edge only.
Pick the protocol split.
Reveal recommended architecture
Tradeoffs. You now maintain two contract styles and a translation layer, which is real surface area — but each is used where it's strongest. The public REST contract can evolve slowly and independently (versioned, backward-compatible) while internal protobuf contracts move fast behind the gateway. The gateway becomes a critical chokepoint you must scale and make HA, but centralizing auth/rate-limit/quota there is far better than scattering them. GraphQL at the edge is worth it instead of REST when partners need flexible, client-shaped queries over many resources; otherwise REST's simplicity and caching win.
8 · Migration from REST to gRPC
A busy internal REST service is a latency + payload bottleneck; you want gRPC without a big-bang cutover.
Requirements
- Cut serialization overhead and enable streaming for chatty callers.
- Migrate callers incrementally — no coordinated flag day.
- Keep a REST shim for the few clients that can't move.
Constraints
- Dozens of independent caller teams; you can't move them all at once.
- Rollback must be instant if p99 or error rate regresses.
- Zero downtime; the service is on the critical path.
What's the safest migration path?
Reveal recommended architecture
Tradeoffs. Running both surfaces from one implementation (grpc-gateway) avoids logic duplication but constrains your protobuf design to what transcodes cleanly to REST during the transition. The incremental, mesh-gated rollout is slower and you carry two protocols for a while, but you buy instant rollback and blast-radius control on a critical-path service — exactly the right trade. The discipline that makes it safe: the protobuf schema is the single contract, canary each caller's cutover, and set a hard sunset date for the REST façade so the "temporary" shim doesn't become permanent tech debt.
9 · DynamoDB vs PostgreSQL
Choosing the primary store for a new high-scale service. The access pattern decides everything.
Requirements
- Predictable single-digit-ms reads/writes at very high, spiky scale.
- But: the product roadmap includes ad-hoc reporting, joins, and evolving queries.
- Operational simplicity is valued; the team is small.
Constraints
- Access patterns for the hot path are known and stable (key-based lookups).
- Analytics needs are flexible and not known up front.
- Strong transactional integrity required for a subset (accounts/ledger).
What's the defensible choice?
Reveal recommended architecture
Tradeoffs. DynamoDB gives near-infinite scale and predictable latency but demands you know your access patterns at design time and offers weak ad-hoc query power — schema changes to access patterns can mean a table redesign. PostgreSQL gives you rich queries, joins, and real transactions but you own the scaling story (read replicas, partitioning, connection limits). Polyglot persistence is the right call here, but it isn't free: two data stores means two operational models, and keeping them consistent (via streams/CDC) introduces eventual consistency between the operational and analytics views. Default to Postgres until a proven access pattern justifies DynamoDB — don't reach for NoSQL scale you don't yet need.
10 · Synchronous vs asynchronous payment
The same payment capability, two very different UX and reliability contracts. When is each right?
Requirements
- Some flows need an immediate approve/decline (checkout, POS).
- Others are fire-and-forget at massive scale (payouts, batch settlements, subscriptions).
- Both must be durable and reconcilable to the cent.
Constraints
- Synchronous couples you to PSP latency and availability in the request path.
- Asynchronous can't give the caller an instant yes/no.
- Payouts have huge, bursty volume with relaxed latency (seconds-to-minutes OK).
How do you decide sync vs async?
Reveal recommended architecture
Tradeoffs. Synchronous is simplest to reason about (one call, one answer) but its availability and latency are hostages to the PSP — mitigate with tight timeouts, a circuit breaker, and idempotency. Asynchronous decouples you from PSP health and scales to bursty volume, but you pay in complexity: a durable queue, worker fleet, retry/DLQ handling, idempotent processing, and a status/webhook mechanism so the caller learns the outcome. Both paths must write a durable record before acknowledging and must reconcile against the PSP's ledger — because in payments, "we lost track of a transaction" is the one failure mode you never accept. Match the pattern to who's waiting, not to a blanket rule.
Failure Engineering
Every distributed system is a failure system that occasionally computes the right answer — design for the failure path, not the happy path.
1 · Database unavailable
Connection-pool acquire timeouts spike; RDS DatabaseConnections flatlines or errors; app-level DB error rate crosses alert threshold; readiness probe on the DB dependency fails.
Every write path and non-cached read path errors or hangs. Requests pile up holding goroutines/threads, exhausting the server's request budget.
Blocked handlers hold pool connections and upstream sockets; callers time out and retry, multiplying load. One dead RDS primary can freeze an entire service tier.
Bounded pool + short context deadlines so a dead DB fails fast, not slow. Circuit breaker on the DB client; serve stale from cache where correctness allows; shed writes early.
Multi-AZ failover promotes a standby (RDS ~60–120s); breaker half-opens and probes; pools reconnect with jittered backoff. Drain the retry backlog gradually to avoid a thundering herd on the fresh primary.
2 · gRPC service unavailable
Client sees UNAVAILABLE (code 14) / connection refused; per-dependency error-rate SLI breaches; Istio/Envoy upstream_rq_5xx and outlier-detection ejections rise.
Any RPC to that service fails; callers that treat it as hard-required return errors to their callers, turning a leaf failure into a user-facing one.
Fan-out amplifies: one degraded service hit by N callers each retrying turns into N× traffic against the survivors and any shared backend.
Per-call deadlines propagated via context; circuit breaker + retry budget (cap retries as % of traffic); bulkhead pools per dependency; graceful degradation to a default/cached response.
K8s reschedules/restarts pods; readiness gating keeps traffic off until healthy; client LB (or Envoy) re-adds endpoints as outlier detection un-ejects them.
3 · Network latency increases
p99/p999 latency climbs while error rate stays low (the classic "brownout" signature); RTT and retransmit metrics rise; queue depth and in-flight request gauges grow.
Concurrency accumulates — by Little's Law, in-flight = arrival rate × latency — consuming connections, memory, and goroutines even at constant RPS.
Slowness flows upstream: callers' deadlines fire, their pools fill, and the latency "back-pressures" hop by hop into user-facing timeouts.
Aggressive timeouts + deadline propagation so slow work is abandoned; hedged/backup requests for read paths; concurrency limits / load shedding; adaptive LB (least-request, EWMA) to route around slow nodes.
Latency normalizes as the congested link/node clears or is drained; in-flight count decays; keep shedding until p99 is back under SLO to avoid re-saturation.
4 · DNS failure
Resolution errors / NXDOMAIN / lookup timeouts in logs; CoreDNS latency + error metrics spike; new connections fail while existing (already-resolved) ones survive — a telltale signature.
Services can't resolve peers (svc.ns.svc.cluster.local) or AWS endpoints (RDS, S3, SQS). Anything establishing a fresh connection stalls; warm pools mask it briefly.
CoreDNS is a shared cluster dependency — its failure fans out to every workload simultaneously, so a DNS blip reads like a whole-cluster outage.
DNS caching (NodeLocal DNSCache) + sane TTLs; scale/PDB CoreDNS; short resolver timeouts; long-lived connection pools reduce lookup frequency; retry with backoff on transient resolution errors.
CoreDNS pods reschedule/scale back up; caches repopulate; connections re-establish. Watch for a reconnect storm once resolution returns.
5 · Pod crashes
Liveness probe fails / container exits non-zero; kubelet reports CrashLoopBackOff; restart-count metric and pod-not-ready alerts fire; per-pod error spike then silence.
In-flight requests on that pod are dropped (RST/reset). Capacity drops by 1/N replicas; if it's a singleton, that endpoint is fully down.
Minimal if replicas >1 and readiness gating works. Danger: crash caused by a poison request or memory leak recurs on the replacement, becoming a rolling crashloop across replicas.
Multiple replicas + readiness probe so traffic only hits healthy pods; preStop + SIGTERM graceful shutdown to drain; resource limits to prevent OOM; retries (idempotent) mask a single dropped request.
Deployment controller restarts the pod (exponential backoff up to 5 min); once Ready, the Service/Endpoints slice re-adds it and traffic resumes automatically.
6 · Node crashes
Node goes NotReady; node-controller heartbeat lost; EC2 status-check failure; a batch of pods simultaneously go unreachable — the multi-pod correlation distinguishes it from a single pod crash.
Every pod on that node dies at once — potentially several services lose a replica simultaneously. If replicas of one service were co-located, that service can lose real capacity.
Correlated loss can breach a service's min-available at once; surviving nodes absorb rescheduled pods and may saturate, spreading pressure.
Pod anti-affinity + topology-spread across nodes/AZs so no single node holds too many replicas; PodDisruptionBudgets; over-provision / headroom so survivors have capacity; multi-AZ node groups.
After the eviction timeout (~5 min) pods reschedule onto healthy nodes; Cluster Autoscaler / Karpenter adds a replacement node; ASG replaces the failed EC2 instance.
7 · Queue backlog increases
Queue depth / ApproximateNumberOfMessagesVisible (SQS) rises; oldest-message age climbs; producer rate > consumer rate on dashboards.
End-to-end processing latency grows (work is queued, not lost). Downstream freshness degrades; time-sensitive messages may breach their usefulness window.
If unbounded, the queue absorbs the shock (good) but hides the root cause; if bounded, producers block or drop, pushing back-pressure upstream. Visibility-timeout redeliveries can amplify load.
The queue itself IS the mitigation (buffering/decoupling). Scale consumers on backlog (KEDA/HPA on queue depth); DLQ for poison messages; idempotent handlers; producer rate-limit if the backlog is pathological.
Added consumer capacity drains the backlog once consume rate > produce rate; oldest-age falls back to baseline. Size consumers for drain time, not just steady state.
8 · Kafka consumer falls behind (lag)
Consumer-group lag (committed offset vs log-end offset) grows per partition; Burrow/Prometheus lag alert fires; processing throughput drops below ingest rate.
Stale reads / delayed side effects downstream. Risk of hitting retention: if lag exceeds the retention window, unconsumed messages are deleted — silent data loss.
Lag is per-partition; a hot key or one slow partition drags a group. A rebalance (consumer join/leave) pauses all consumption briefly, spiking lag further.
Scale consumers up to the partition count (parallelism ceiling = #partitions); increase partitions for headroom; batch/async processing; fix slow handlers; static membership to avoid needless rebalances.
Consumers catch up when throughput > ingest and commit forward; lag trends to zero. If retention was breached, gaps are permanent — reprocess from a source of truth if one exists.
9 · Downstream returns 500
Downstream 5xx rate crosses threshold (a golden signal — errors); traces show the failing span; per-dependency error SLI breaches.
Requests depending on it fail. Whether the user sees an error depends on whether the call is on the critical path or has a fallback.
A hard, fast 5xx is the "nice" failure — it fails quickly and clearly. Danger comes from naive retries turning a struggling downstream into a fully dead one (retry amplification).
Retry only idempotent ops, with backoff+jitter and a retry budget; circuit breaker to stop hammering; fallback / default response / cached value; return a clear degraded response rather than blocking.
Downstream recovers (its own restart/failover); breaker half-opens and probes; error rate falls. Ramp retries back gradually.
10 · Downstream returns slowly
Downstream call latency rises while its error rate stays low — the brownout signature; tail latency (p99) diverges from p50; in-flight-to-that-dependency gauge climbs.
Slow is worse than dead. Without a timeout, callers block indefinitely, accumulating held connections/goroutines until the caller itself exhausts capacity.
Resource exhaustion climbs the call graph: each hop's pool fills waiting on the slow hop, cascading a single slow dependency into a multi-service brownout.
Mandatory timeouts + deadline propagation (a call with no deadline is a bug); bulkheads to cap concurrency per dependency; hedged requests; breaker on slow-call ratio, not just errors; load shedding.
Timeouts free held resources so the caller survives; once the downstream speeds up, in-flight decays and normal throughput returns.
11 · Response is lost
Client times out with no reply while the server logged success — a request/response mismatch; TCP resets or LB idle-timeout drops mid-response show in access logs.
The ambiguous outcome problem: the operation may have succeeded server-side, but the client can't know. Naive retry may double-apply a non-idempotent effect (charge, order).
If the client retries a completed side effect, you get duplicate work / double writes downstream — a data-integrity fault propagating past the network glitch.
Idempotency keys so a retry is a no-op server-side; design mutations to be idempotent (upsert, dedup on request id); at-least-once delivery + idempotent consumers; align LB idle timeout > server timeout.
Client retries safely (idempotency makes the duplicate harmless) and gets a definitive answer; reconciliation jobs catch any stragglers.
12 · Duplicate request occurs
Same idempotency/request key seen twice; duplicate-hit counter or dedup-store metric rises; downstream sees repeated messages (at-least-once queues guarantee this happens).
Without protection: double charges, duplicate orders, inflated counters, double-processed events — a correctness bug, not an availability one.
Duplicates multiply through fan-out: one duplicate event can trigger duplicate downstream effects across every subscriber that isn't idempotent.
Treat duplicates as normal, not exceptional: idempotency keys with a dedup store (Redis/DynamoDB conditional put); exactly-once effects via idempotent handlers; unique constraints; outbox pattern for reliable, dedup'd publishing.
Dedup layer collapses the duplicate to a single effect automatically — no manual recovery needed. Alert only if the dedup store itself is unavailable.
Security for Service-to-Service Communication
In-cluster traffic is not "trusted by default" — zero-trust means every hop proves who it is (identity/authN) and what it may do (authZ), over an encrypted channel.
Core mechanisms
TLS — encrypt the channel, authenticate the server
- What
- One-way TLS: client verifies the server's cert against a CA; session is encrypted. The server does not verify the client.
- Problem it solves
- Eavesdropping and tampering on the wire; confidence you're talking to the real server (not a MITM).
- When to use
- Any external/public endpoint (browser→ALB, client→API gateway). Table stakes for internet traffic.
- Tradeoffs
- Proves nothing about the caller. Fine at the edge behind another authN layer (JWT/session); insufficient alone for zero-trust service-to-service.
mTLS — both sides prove identity with certs
- What
- Mutual TLS: server verifies client cert and client verifies server cert. Both ends hold a certificate tied to a workload identity.
- Problem it solves
- Strong, cryptographic service identity + encryption in one handshake — the foundation of zero-trust east-west traffic.
- When to use
- Service-to-service inside/between clusters; anywhere you can't trust the network. Default posture in a mesh.
- When NOT to
- As an authZ mechanism — mTLS says who, not what they may do. Pair with policy.
- In production
- Istio/Linkerd issue + rotate short-lived certs automatically per pod; near-zero developer effort once the mesh is in place.
Authentication — proving identity
- What
- Establishing who a caller is: a workload (via mTLS cert / SPIFFE ID / IAM role) or an end user (via JWT / session).
- Problem it solves
- Prevents impersonation; gives every downstream decision a trustworthy subject to reason about.
- Architecture impact
- Decide the trust boundary: authenticate the user once at the edge, then authenticate the service on every internal hop.
Authorization — proving permission
- What
- Deciding what an authenticated subject may do: RBAC/ABAC, OAuth2 scopes, IAM policies, Istio
AuthorizationPolicy, OPA/Rego. - Problem it solves
- Least privilege — a compromised or buggy caller can only do what policy allows, limiting blast radius.
- When to use
- Always, after authN. Enforce at the mesh (coarse: service A may call service B) and in-app (fine: this user may edit this record).
- Tradeoffs
- Coarse mesh policy is cheap but blunt; fine app policy is precise but couples logic to authZ. Layer both.
Service identity — SPIFFE/SVID, IAM roles, K8s ServiceAccounts
- What
- A verifiable name for a workload. SPIFFE ID (
spiffe://cluster/ns/sa) delivered as an SVID (an X.509 cert or JWT); on AWS, an IAM role; in K8s, a ServiceAccount projected as a token (IRSA/Pod Identity binds SA→IAM role). - Problem it solves
- Identity that isn't a shared secret and isn't tied to an IP — the anchor for mTLS and for AWS API authZ.
- In production
- Mesh maps SPIFFE ID→cert automatically; IRSA lets a pod's ServiceAccount assume an IAM role to call S3/SQS/RDS with no static keys.
API keys — coarse, shared caller credential
- What
- A static opaque token identifying a caller/app (not a user), passed in a header.
- Problem it solves
- Simple attribution, quotas, and rate-limiting for third-party or partner API access.
- When NOT to
- As real authN between your own services (it's a bearer secret — leak = full impersonation, no expiry, no identity binding). Prefer mTLS/IAM internally.
- Tradeoffs
- Easy to issue, hard to rotate/scope; no cryptographic proof of the holder. Store hashed, rotate often.
JWT — signed claims, stateless authN/authZ
- What
- A signed (JWS) token carrying claims (
sub,aud,exp, scopes/roles). Verifier checks the signature against the issuer's public key — no callback needed. - Problem it solves
- Stateless propagation of user identity + coarse permissions across service hops without a shared session store.
- When NOT to
- For long-lived sessions you must revoke instantly (JWTs live until
exp) — keep them short and pair with refresh tokens. - Tradeoffs
- Great for authZ claims; validate
aud/iss/exprigorously or it's worse than nothing. Bearer token → protect in transit (TLS).
OAuth2 — delegated authorization framework
- What
- A framework for issuing scoped access tokens (often JWTs). Client-credentials grant is the service-to-service flow; authorization-code is for user delegation.
- Problem it solves
- Delegated, scoped access without sharing passwords; centralized token issuance via an authorization server (Cognito, Auth0, Keycloak).
- When to use
- User-facing delegation and machine-to-machine tokens with scopes; anywhere you want a central IdP to mint short-lived credentials.
- Tradeoffs
- Powerful but complex (grant types, PKCE, refresh, introspection). Adds an IdP dependency on the auth path.
IAM — cloud-native identity & policy (AWS)
- What
- AWS's identity + policy engine. Roles = workload identity; policies = authZ; SigV4-signed requests = authN to AWS APIs. IRSA/Pod Identity gives pods a role.
- Problem it solves
- Keyless, auditable, least-privilege access to AWS services (S3, SQS, Secrets Manager, RDS IAM auth) — no static access keys in pods.
- In production
- Scope roles per workload; CloudTrail audits every call. IAM handles authN+authZ to AWS; it does not secure your own service-to-service RPC — that's the mesh's job.
Secrets management — store, inject, audit
- What
- Centralized storage + controlled delivery of credentials (AWS Secrets Manager, Vault, K8s Secrets + external-secrets/CSI driver).
- Problem it solves
- Keeps secrets out of images/env/git; enables rotation, access audit, and least-privilege retrieval.
- When NOT to
- Don't lean on raw K8s Secrets alone (base64, not encrypted unless you enable KMS envelope encryption at rest).
- In production
- Mount via CSI/external-secrets so rotation flows without redeploy; gate reads with IAM/Vault policy tied to the workload's identity.
Certificate rotation — short-lived, automated
- What
- Continuously re-issuing workload/CA certs on a short TTL (hours) so a leaked cert is worthless quickly.
- Problem it solves
- Removes long-lived credentials as an attack target; makes revocation moot (certs expire faster than they can be abused).
- In production
- Istio Citadel/SDS or SPIRE rotates SVIDs automatically with zero downtime; cert-manager handles ingress/public certs. The failure to plan for is the outage when rotation breaks, so alert on cert age/expiry.
Who owns what: application vs infrastructure security
What it handles: the semantics only the app knows — user authN (validate JWT/session), fine-grained authZ ("does user 42 own this order?"), input validation, business-rule permissions, per-field data protection.
- Owned by: application/product engineers — it lives in code and moves with the domain model.
- Strength: maximum precision; can reason about resources, tenants, and business context the network cannot see.
- Tradeoff: couples security to app code; every service must re-implement it correctly; easy to get subtly wrong; changes require redeploys.
Example: middleware verifies the JWT signature + aud, then a handler checks the user's role against the target resource.
What it handles: transport and identity plumbing — mTLS encryption + workload authN, coarse service-to-service authZ (AuthorizationPolicy: A may call B), cert issuance/rotation, network policy, all transparent to the app.
- Owned by: platform / SRE team — configured declaratively (Istio CRDs, NetworkPolicy), uniform across every service.
- Strength: zero app code; consistent by default; language-agnostic; rotation and encryption "for free" for polyglot fleets.
- Tradeoff: can't see business context (no notion of "this user owns this record"); adds a sidecar's latency/resource overhead and operational complexity.
Rule of thumb: mesh secures the channel + workload identity; the app secures the user + the data. You need both — defense in depth.
Where identity & encryption are enforced, per hop
Mechanism comparison — what does it actually prove?
| Mechanism | Encryption | Authentication (who) | Authorization (what) | Where it lives |
|---|---|---|---|---|
| TLS | Yes | Server only | No | Transport / edge (ALB, ingress) |
| mTLS | Yes | Both (workload) | No | Service mesh sidecar (Istio/Linkerd) |
| JWT | No* | Yes (user/subject) | Via scopes/claims | App layer (token in header) |
| OAuth2 | No* | Yes (delegated) | Yes (scopes) | IdP + app (Cognito/Auth0/Keycloak) |
| API key | No* | Weak (caller app) | Coarse (quota/plan) | API gateway / app header |
| IAM | In transit (to AWS) | Yes (role) | Yes (policy) | AWS control plane (SigV4, IRSA) |
* relies on an underlying TLS channel for confidentiality — the token itself is a bearer credential, not an encryptor.
A request arrives at your Go service over the mesh. mTLS validated the peer certificate and a JWT is present in the header. Which statement is correct?
Cost & Scalability
Architecture is an economic argument in disguise — every design choice draws a bill from CPU, memory, network, storage, and the humans who operate it. Learn to read the invoice before you commit the diagram.
Where the money actually goes
Cloud spend is not one number; it is a portfolio of cost centers that each scale on a different axis. The architect's job is to know which axis a given decision pushes on — and whether that axis grows linearly, super-linearly, or steps up in painful jumps.
Cost that scales with compute
- CPU / memory: node hours on EKS, vCPU-seconds on Lambda/Fargate. Synchronous request-response holds a goroutine (and its RAM) for the full downstream latency — you pay for waiting.
- Load balancers: ALB/NLB charge per hour and per LCU (new connections, active connections, bandwidth, rule evals). Fan-out microservices multiply LB count.
- Kubernetes nodes: you pay for provisioned capacity, not utilization. Over-provisioning for peak = paying peak price 24/7.
- Serverless: pay-per-invocation + duration. Great at low/spiky volume, expensive at sustained high throughput where a reserved node amortizes better.
Cost that scales with data & traffic
- Database: RDS = instance hours + provisioned IOPS + storage. DynamoDB = RCU/WCU or on-demand per-request. Chatty sync calls inflate read units.
- Network egress: internet egress is the classic trap; cross-AZ transfer is the silent one (below).
- Storage: S3/EBS scale with volume; queue retention (SQS/Kafka) is cheap buffer that trades storage for compute smoothing.
- Request volume: API Gateway, per-request DB units, and per-message queue charges all track traffic — but async lets you decouple the rate you pay from the rate you receive.
The central decision: Synchronous gRPC vs SQS-based async
Same business outcome — Order tells Payment to charge a card. Two architectures, two completely different cost and scaling profiles. Flip between them.
Behavior: Order blocks a goroutine until Payment returns. Latency is the sum of the chain. A spike hits Payment immediately at full amplitude — you must have the compute already running to absorb it.
- Simple mental model; strong consistency; instant success/failure to the caller; easy tracing (one span tree).
- Peak-provisioned: both services sized for the spike → you pay peak 24/7.
- Tight coupling: Payment latency/outage becomes Order latency/outage. Retries amplify load precisely when the system is stressed.
- Every cross-service hop can cross an AZ boundary at request rate → per-request egress charges.
Behavior: Order writes a message and returns in milliseconds. Workers drain the queue at their pace. A traffic spike lands as queue depth (cheap storage), not as compute pressure — you buffer instead of over-provision.
- Elastic & cheap at rest: workers scale on queue depth, down to zero. You pay for work done, not for waiting.
- Decoupled failure domains: Payment can be down for minutes; messages wait. Natural retry + DLQ.
- Absorbs spikes without peak-sizing compute — the queue is the shock absorber.
- Eventual consistency; caller gets "accepted", not "charged". Needs status callbacks/polling.
- More moving parts: idempotency, DLQs, ordering, visibility timeouts, poison-message handling.
| Dimension | A · Synchronous gRPC | B · SQS-based async |
|---|---|---|
| Latency (caller-perceived) | low, blocking sum of the chain | very low to enqueue, work completes later |
| End-to-end completion | immediate | eventual (ms→seconds under load) |
| Cheaper | no peak-provisioned | yes scale-to-zero, pay-per-work |
| More scalable | bounded by slowest hop | yes queue absorbs spikes |
| Easier to operate | yes fewer parts, one trace | more parts (DLQ, idempotency) |
| More reliable under stress | cascades on downstream failure | yes isolated, retriable |
| More complex | simpler | yes |
topologyKeys / service.kubernetes.io/topology-mode: Auto), zone-local read replicas, and keeping chatty sync partners co-located. The async pattern helps here too — one batched enqueue crosses an AZ far less often than a thousand fine-grained sync calls.
Your multi-AZ EKS bill has a mysterious, large data-transfer line item. Where are the cross-AZ charges most likely hiding?
Capstone Project
Build a production-style distributed order system in Go — but the deliverable is not code, it's a chain of defensible architecture decisions. Anyone can wire services together; the value is choosing the boundaries and justifying them.
The system at a glance
The services
Order Service
- Responsibility: owns the order lifecycle — create, validate, orchestrate checkout, track state.
- Datastore: Postgres (relational, transactional, needs joins + strong consistency on state transitions).
- Boundaries: sync gRPC to User (validate) & inventory check; async event to Payment & Notification.
Payment Service
- Responsibility: charge cards via external PSP, track payment state, handle refunds.
- Datastore: Postgres (money demands ACID, audit trail, exactly-once semantics on ledger rows).
- Boundaries: consumes async "order placed" events; must be idempotent — the PSP call cannot double-charge.
User Service
- Responsibility: identity, profile, addresses, auth claims.
- Datastore: Postgres or DynamoDB — high read:write, simple key access → DynamoDB is a fine fit; heavy relational profile data → Postgres.
- Boundaries: sync gRPC reads from Order (low-latency validation), cached in Redis.
Notification Service
- Responsibility: email/SMS/push on order & payment events.
- Datastore: DynamoDB (append-heavy delivery log, TTL, no joins) — or none, purely event-driven.
- Boundaries: fully async — never on the critical path; a notification failure must not fail an order.
Requirements to satisfy
API surface
- REST at the public edge (browser/mobile clients, human-readable, cacheable).
- gRPC for internal service-to-service (typed contracts, fast, streaming, low overhead).
- Async events (SQS/Kafka) for cross-service side effects that don't block the caller.
Data
- Per-service database ownership — no shared DB, no cross-service table joins.
- Redis cache-aside for hot reads (user/session lookups); also stores idempotency keys.
Reliability
- Retries with backoff + jitter on transient failures.
- Deadlines / timeouts propagated via
context.Contexton every gRPC call. - Idempotency keys so retried payments never double-charge.
- Graceful shutdown — drain in-flight requests on SIGTERM before pod exit.
- Health checks — liveness & readiness probes distinct.
Observability
- Metrics (Prometheus): RED per service, queue depth, DLQ count.
- Structured logs with correlation/trace IDs.
- Distributed tracing (OpenTelemetry) across REST → gRPC → queue → worker.
Platform
- Containerized, deployed to Kubernetes (EKS) with HPA driven by CPU + queue depth.
The decisions that matter
This is the graded part. Form your own answer first, then reveal a defensible one — and note that "defensible" means the tradeoff is stated, not that there's a single right answer.
Order → Payment: sync or async?
Async. Order writes an "order placed" event and returns immediately; a Payment worker consumes it. Why: payment involves a slow, flaky external PSP — coupling it synchronously makes every checkout as slow and fragile as the PSP. Async lets Order confirm fast ("we've received your order"), absorbs PSP spikes/outages in the queue, and gives natural retry + DLQ. Tradeoff: the customer sees "processing", not "paid" — you need a status callback (webhook/polling/websocket) and a saga to compensate if payment ultimately fails. If the business truly requires synchronous authorization at checkout (e.g. instant reject on declined card), a hybrid works: sync authorize (fast, bounded), async capture/settle.
How is idempotency enforced on Payment?
Client-generated idempotency key per order attempt, persisted before the PSP call. Flow: Order includes a unique key in the event → Payment worker does a conditional insert (INSERT ... ON CONFLICT DO NOTHING in Postgres, or a conditional write in DynamoDB / a Redis SETNX) keyed on that value → if the row already exists in a terminal state, return the stored result instead of re-charging. Why it's non-negotiable: at-least-once delivery (SQS, Kafka) will redeliver messages, and retries fire exactly when the system is stressed. Without idempotency you double-charge customers. The idempotency record must be written in the same transaction as the charge outcome, or you get a gap where a crash loses the guarantee.
REST at the edge, gRPC internally — why not one everywhere?
Different audiences, different constraints. REST/JSON at the edge: browsers and mobile speak HTTP natively, it's cacheable at CDN/gateway, human-debuggable, and firewall-friendly. gRPC internally: strongly-typed contracts (protobuf) catch breaking changes at compile time, HTTP/2 multiplexing + binary encoding cut latency and CPU, and streaming is first-class. Tradeoff: gRPC through browsers needs grpc-web + a proxy — friction you don't want at the edge. So you terminate REST at the API service (a BFF) and translate to gRPC behind it. One protocol everywhere would either make the edge awkward (gRPC to browsers) or leave internal calls slow and loosely-typed (JSON between services).
Which datastore per service — and why not one shared DB?
Match the store to the access pattern, and never share. Order & Payment → Postgres (transactional integrity, joins, ACID for money and state machines). User → DynamoDB if access is key-based and read-heavy (predictable single-digit-ms, scales without ops), Postgres if profile data is relational. Notification → DynamoDB or none (append-only delivery log with TTL). Why no shared DB: a shared schema recouples the services you worked to decouple — one team's migration breaks another, and you lose independent scaling and failure isolation. Each service owning its store is what makes the microservice boundary real; cross-service data is fetched via gRPC or events, never a JOIN.
The Architect's Checklist
The final tool: a portable interrogation you run against any technology — a language, a database, a queue, a framework, a managed service — before it earns a place in your architecture.
- 1. What problem does it solve? If you can't state the specific pain it removes, you're adopting it for fashion, not fit.
- 2. Why was it created? The origin story reveals the constraints it optimizes for — and whether those are your constraints.
- 3. What alternatives exist? A choice made without comparison isn't a decision, it's a default; know what you're rejecting and why.
- 4. When should I use it? The sweet spot where its strengths dominate tells you the workloads it was built to win.
- 5. When should I avoid it? Every tool has a failure zone; knowing it prevents forcing a square peg into an expensive hole.
- 6. What are its scaling characteristics? Linear, logarithmic, or a cliff — how it behaves as load grows dictates your architecture's ceiling.
- 7. What happens when it fails? Fail-open, fail-closed, silent data loss, or cascade — failure mode shapes your blast radius and recovery plan.
- 8. How do I observe it? If you can't get metrics, logs, and traces out of it, you can't operate it — you can only hope.
- 9. How do I secure it? AuthN/AuthZ, encryption, patching cadence, and attack surface decide whether it's a liability in production.
- 10. How does it affect cost? Which axis does it bill on — compute, storage, requests, egress — and does that axis track your growth?
- 11. How does it affect operations? Upgrades, backups, on-call load, and day-2 toil are the price you pay long after the demo impressed everyone.
- 12. What does it lock me into? Proprietary APIs, data formats, and control planes set the cost and feasibility of ever leaving.
- 13. What happens at 10x scale? The design that's comfortable today often reveals its first bottleneck one order of magnitude up.
- 14. What happens at 100x scale? Two orders up usually breaks a core assumption entirely — knowing where tells you if this is a bridge or a dead end.
- 15. What is the simplest architecture that satisfies the requirement? The best answer is frequently less technology, not more — complexity is a cost you pay forever.