- Book: Hexagonal Architecture in Go
- Also by me: The Complete Guide to Go Programming — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A customer taps "Pay" once. The button spins. Nothing happens for
two seconds, so they tap it again. Your Go service now has two
POST /charges requests in flight for the same cart. Both pass
validation. Both call the payment provider. The customer gets
charged twice and opens a support ticket.
This is not a rare edge case. Mobile networks retry. Load
balancers retry on timeout. Client SDKs retry on 5xx. A gateway
that returns 504 while your handler is still running will happily
send the same request again, and your handler has no idea the
first one is still working.
Stripe's API solved this with a header the client controls: the
Idempotency-Key.
The client generates a unique key per logical operation and sends
it on every retry of that operation. The server promises that a
given key runs at most once, and every retry gets the same
response the first attempt produced. This post builds that in Go,
from the store to the handler, and shows where the check belongs
relative to your use case.
The contract
An idempotent endpoint makes two promises:
- The side effect (the charge, the order, the email) happens once per key, no matter how many times the request arrives.
- Every request carrying that key gets the same response body and status the first one produced, even the retries that arrive while the first is still running.
The mechanism is a key store with three states per key:
in-flight, completed, and absent. The first request to claim
a key wins and does the work. Everyone else either waits or
replays the stored result. First-write-wins is the whole game.
The store interface
Start with a port, not a Redis client. The handler should not
know where keys live.
package idempotency
import (
"context"
"time"
)
// Record is the stored outcome for one key.
type Record struct {
Status int
Body []byte
Completed bool
}
type Store interface {
// Claim tries to reserve key. If it already exists,
// ok is false and the existing Record comes back.
Claim(
ctx context.Context, key string, ttl time.Duration,
) (rec Record, ok bool, err error)
// Save writes the final response for a claimed key.
Save(
ctx context.Context, key string, rec Record,
) error
}
Claim is the atomic first-write-wins primitive. If it returns
ok == true, this caller reserved the key and owns the work. If
ok == false, someone got there first, and rec tells you
whether they finished.
A Postgres store with first-write-wins
Postgres gives you atomic claim through INSERT ... ON CONFLICT. The row either inserts (you won) or does not (someone
DO NOTHING
else holds it).
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
status int NOT NULL DEFAULT 0,
body bytea NOT NULL DEFAULT '',
completed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL
);
func (s *PGStore) Claim(
ctx context.Context, key string, ttl time.Duration,
) (Record, bool, error) {
exp := time.Now().Add(ttl)
tag, err := s.db.Exec(ctx, `
INSERT INTO idempotency_keys (key, expires_at)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
`, key, exp)
if err != nil {
return Record{}, false, err
}
if tag.RowsAffected() == 1 {
return Record{}, true, nil // we claimed it
}
// Someone else holds it. Read their state.
var rec Record
err = s.db.QueryRow(ctx, `
SELECT status, body, completed
FROM idempotency_keys
WHERE key = $1
`, key).Scan(&rec.Status, &rec.Body, &rec.Completed)
if err != nil {
return Record{}, false, err
}
return rec, false, nil
}
RowsAffected() == 1 is the win condition. It is atomic at the
database level, so two goroutines racing the same key can never
both see a win. One inserts, the other conflicts. No application
lock, no SELECT then INSERT gap for a race to slip through.
Save writes the outcome back once the work is done:
func (s *PGStore) Save(
ctx context.Context, key string, rec Record,
) error {
_, err := s.db.Exec(ctx, `
UPDATE idempotency_keys
SET status = $2, body = $3, completed = true
WHERE key = $1
`, key, rec.Status, rec.Body)
return err
}
The middleware
Wrap the handler once and every route it covers becomes
idempotent. The middleware reads the header, claims the key, and
either runs the inner handler or replays the stored response.
func Middleware(store Store, ttl time.Duration) func(
http.Handler,
) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
w http.ResponseWriter, r *http.Request,
) {
key := r.Header.Get("Idempotency-Key")
if key == "" {
next.ServeHTTP(w, r)
return
}
rec, won, err := store.Claim(
r.Context(), key, ttl,
)
if err != nil {
http.Error(w, "store error", 500)
return
}
if !won {
replay(w, rec)
return
}
// We own the key. Capture the response.
rw := &capture{ResponseWriter: w, status: 200}
next.ServeHTTP(rw, r)
// Only persist terminal responses. A 5xx is
// usually transient, so storing it would replay
// the failure to every future retry with this key.
if rw.status < 500 {
_ = store.Save(r.Context(), key, Record{
Status: rw.status,
Body: rw.buf.Bytes(),
})
}
})
}
}
replay handles the two not-won cases: the first request
finished, or it is still running.
func replay(w http.ResponseWriter, rec Record) {
if !rec.Completed {
// First request still in flight.
w.Header().Set("Retry-After", "1")
http.Error(w, "request in progress", 409)
return
}
w.WriteHeader(rec.Status)
_, _ = w.Write(rec.Body)
}
A retry that lands while the original is still working gets a
409 with Retry-After, not a duplicate charge. A retry that
lands after the original finished gets the exact stored response.
Both promises hold.
One caveat on the capture path: only persist terminal responses.
A 5xx is usually transient, and storing it would replay that
failure to every future retry carrying the key. Gating Save on
rw.status < 500 keeps a transient error out of the store. A
production version also releases the claim on failure (deletes
the in-flight row) so the next retry can re-run the work instead
of parking on 409 until the TTL expires.
The capture type is a small ResponseWriter wrapper that
records what the handler wrote so the middleware can store it:
type capture struct {
http.ResponseWriter
status int
buf bytes.Buffer
}
func (c *capture) WriteHeader(code int) {
c.status = code
c.ResponseWriter.WriteHeader(code)
}
func (c *capture) Write(b []byte) (int, error) {
c.buf.Write(b)
return c.ResponseWriter.Write(b)
}
Where the check sits relative to the use case
This is the part most tutorials skip, and it is the part that
decides whether the pattern actually protects you.
The idempotency check has to sit outside the use case, in the
transport layer, before the handler touches the domain. Think of
it as the outermost ring in a hexagonal layout: HTTP concerns
(the header, the key store, the replay) live in an adapter. The
use case underneath (ChargeCart, PlaceOrder) knows nothing
about keys. It runs, or it does not run at all.
mux := http.NewServeMux()
mux.Handle("POST /charges", chargeHandler)
// Idempotency wraps the transport, not the domain.
handler := idempotency.Middleware(store, 24*time.Hour)(mux)
http.ListenAndServe(":8080", handler)
Put the check inside the use case and you leak an HTTP concern
into your domain. ChargeCart should not take an
idempotencyKey string. It should not know that HTTP retries
exist. Keep the concern in the adapter and the use case stays
testable without a store.
There is a deeper reason the boundary matters: the atomic claim
and the side effect must not drift apart. If the claim lives in
the middleware but the charge commits in a database transaction
the middleware cannot see, a crash between Save and the
handler's own commit leaves you with a completed key and no
charge. For side effects that must be exactly-once with their
key, fold the claim into the same transaction as the work — claim
and side effect commit together, or neither does. The middleware
handles the general case; the money path deserves the tighter
coupling.
What a key must not do
An idempotency key is a promise about one logical operation, so
guard it:
-
Bind the key to the request. Store a hash of the request
body alongside the key. If the same key arrives with a
different body, reject with
422— the client reused a key for a different operation, which is a bug you want surfaced, not silently served the wrong stored response. -
Give it a TTL. Keys are not permanent. A day is a common
window.
expires_atplus a sweep job keeps the table from growing without bound. -
Only key mutations.
GETis already idempotent by HTTP's own definition. WrapPOST,PATCH, andDELETEwhere a double-run causes damage.
The full request path
Put together, one key flows like this. First POST with key
abc: Claim inserts, wins, the handler charges the card,
Save stores 200 and the body. Second POST with abc two
seconds later while the first still runs: Claim conflicts,
completed is false, the client gets 409 Retry-After: 1. Third
POST with abc after the first finished: Claim conflicts,
completed is true, the client gets the exact 200 from attempt
one. One charge, three requests, no double processing.
The customer can tap the button as many times as their patience
allows. Your Go handler charges them once.
If this was useful
The store, the middleware, and the use case underneath are a
clean hexagonal split: an adapter owns the HTTP-level idempotency
concern, the domain stays ignorant of it, and the two meet at a
port. Hexagonal Architecture in Go walks that boundary in
depth — where transport concerns stop and the use case begins,
and how to keep the two from bleeding into each other. The
Complete Guide to Go Programming covers the runtime pieces this
leans on: ResponseWriter wrapping, context propagation, and
the database/sql transaction semantics the money path needs.

Top comments (0)