Table of contents
A write path takes bytes from a client that may be hostile, careless or merely retrying, and turns them into a row. Three questions sit between: is this readable, is it allowed, and is it already there.
Decoding
// internal/httphandlerpub/item.go
// maxItemBodySize caps the request payload accepted by the item endpoints. The
// body holds a name and a quantity, so anything larger is rejected before it is
// decoded.
const maxItemBodySize = 4 * 1024
func (h *HTTPHandlerPublic) handleCreateItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req createItemRequest
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxItemBodySize))
dec.DisallowUnknownFields()
err := dec.Decode(&req)
if err != nil {
h.httpres.SendStatus(ctx, w, http.StatusBadRequest)
return
}
The cap is sized to the endpoint rather than to the server. 4KB is generous for a name and a number, and it is the difference between a thousand concurrent requests allocating nothing and a thousand requests each allocating whatever a client chose to send. Page 08’s server-wide cap is a backstop measured in megabytes and does not tell you anything about this payload.
DisallowUnknownFields turns {"name":"bolt","quantiy":3} into a rejection
instead of an item with a quantity of zero. Without it the typo decodes
silently, the client believes it set the field, and nothing in the response says
otherwise. The cost is real: adding a field to your own client before the server
that accepts it produces a rejection, and a client that echoes a response back
cannot send fields the request type lacks. Against clients you do not control,
that trade is catching typos against tolerating extra fields.
Both failures answer 400, because a body the decoder could not read is a request built wrong rather than data that is wrong. Page 10 drew that line.
The quantity JSON cannot express
// createItemRequest is the wire shape of a create request.
//
// Quantity is kept raw rather than decoded straight into a uint32 because JSON
// has a single number type: a client is entitled to write a whole number as
// 3.0 or 3e0, and the standard decoder rejects both for an integer field.
// itemQuantity does the checking the decoder cannot.
type createItemRequest struct {
Name string `json:"name"`
Quantity json.RawMessage `json:"quantity"`
}
JSON has one numeric type, a double. 3, 3.0 and 3e0 are the same value in
the specification and three different strings on the wire, and a client
assembling a request from a language where every number is a float sends the
second or the third without meaning anything by it. json.Unmarshal into a
uint32 accepts only the first.
Deferring the conversion moves it somewhere it can be decided rather than inherited:
// An absent quantity is zero. Anything else must be a JSON number holding a
// whole value within the range of the INT UNSIGNED column: a string, a null or
// a fraction is a validation failure, which keeps the rejection a 422 instead
// of letting the database refuse the insert with a 500.
func itemQuantity(raw json.RawMessage) (uint32, error) {
if len(raw) == 0 {
return 0, nil
}
var decoded any
err := json.Unmarshal(raw, &decoded)
v, ok := decoded.(float64)
if err != nil || !ok || v != math.Trunc(v) || v < 0 || v > math.MaxUint32 {
return 0, fmt.Errorf("%w: quantity must be a whole number between 0 and %d", item.ErrValidation, uint32(math.MaxUint32))
}
return uint32(v), nil
}
Five conditions on one line, each of them a body somebody will send. A string
where a number belongs, an explicit null, 3.5, -1, and 4294967296, the first value the column cannot hold. All five are 422 and none of them
reaches the database.
That last one is why the check is here rather than in the service. uint32 is
the range of INT UNSIGNED (page 14), so
narrowing at the edge means no value that gets past this function can fail the
insert. Without it, a quantity of four billion and change is a 500 from a
constraint violation, and the client is told the service broke when the request
was wrong.
Validating
The rules live in the service, in plain Go:
// internal/item/service.go
func (s *Service) Create(ctx context.Context, p CreateParams) (*Item, error) {
if p.Name == "" {
return nil, fmt.Errorf("%w: empty name", ErrValidation)
}
// The bound is in characters, not bytes, because that is what the VARCHAR
// column counts.
if utf8.RuneCountInString(p.Name) > MaxNameLength {
return nil, fmt.Errorf("%w: name longer than %d characters", ErrValidation, MaxNameLength)
}
utf8.RuneCountInString rather than len. A VARCHAR(128) under utf8mb4
counts characters and len counts bytes, so a name of 128 emoji is 512 bytes
and fits the column that a byte check would have refused. Getting this backwards
produces a service that rejects perfectly valid names the moment somebody writes
in Japanese, and it passes every test written in English.
The list rule is the other shape of the same decision:
// A zero limit selects DefaultPageSize; a limit above MaxPageSize is rejected
// rather than silently reduced, so the caller is told the page it asked for is
// not served.
func (s *Service) List(ctx context.Context, p ListParams) ([]Item, error) {
if p.Limit > MaxPageSize {
return nil, fmt.Errorf("%w: limit above %d", ErrValidation, MaxPageSize)
}
Refusing rather than clamping is a choice, and the argument for it is that a
client asking for 500 and receiving 100 has no way to tell whether the other 400
exist. Clamping is defensible where the parameter is a hint; here the
specification publishes maximum: 100 and the service enforces the number it
published.
Where the rules go, and what a validator would add
The validator package from page 04 is
not used on requests here. At two rules, if statements are shorter than the
tags and the plumbing that reads them. The package earns its place when a
payload has a dozen fields, because its ValidateStruct returns an
errors.Join aggregate with one typed error per failed rule, and a client with
three problems learns about all three instead of fixing one per round trip.
Wiring it for requests differs from the configuration case in one option:
v, err := validator.New(
validator.WithFieldNameTag("json"),
validator.WithCustomValidationTags(validator.CustomValidationTags()),
validator.WithErrorTemplates(validator.ErrorTemplates()),
)
WithFieldNameTag("json") rather than "mapstructure", so the error namespaces
use the names the client sent rather than the names the configuration loader
uses.
The same library in two places has opposite consequences:
| Configuration | Request | |
|---|---|---|
| When | Once, at startup | Every request |
| On failure | The process exits 2 | 422, the service keeps serving |
| Who fixes it | An operator | The client |
| The message goes to | The startup log | The response body |
The second has a security dimension the first does not. A configuration error
message can say anything, since only an operator reads it. A request error
message is read by whoever sent the request, so it carries no internal detail
and no echo of the input. The built-in templates interpolate the field namespace
and the rule parameter and never the offending value, and a custom template
should not either. The Value field exists and its own documentation is blunt
about it:
It holds the raw input and is exposed to error templates, so avoid echoing it into messages for sensitive fields.
Identity and time
// Create stores a new item, assigning it a UUIDv7 identifier and a creation
// time. The time is truncated to microseconds to match the DATETIME(6) column,
// so the returned item equals the stored one.
it := &Item{
ID: s.rnd.UUIDv7().String(),
Name: p.Name,
Quantity: p.Quantity,
CreatedAt: time.Now().UTC().Truncate(time.Microsecond),
}
UUIDv7 puts a millisecond timestamp in the high bits, so identifiers generated later sort later. On an InnoDB primary key that matters: a random identifier inserts into the middle of the index and splits pages, where a time-ordered one appends. It also means the identifier leaks a creation time, information a random one does not give away.
Truncate(time.Microsecond) is the fiddly one. Go’s time.Time carries
nanoseconds, DATETIME(6) stores microseconds, and the driver rounds on the way
in. Without the truncation, the item returned in the 201 body and the item the
next GET reads differ in the last three digits of created_at, the kind of difference that survives every test written against a mock and fails the
first assertion written against a real database.
The transaction
// internal/item/repository.go
// Create inserts one item, mapping a duplicate name to ErrConflict.
//
// A single insert does not need a transaction; it is written inside one because
// the transaction helper is the thing worth showing here, and one statement is
// the smallest example of it.
func (r *Repository) Create(ctx context.Context, it *Item) error {
err := sqltransaction.Exec(ctx, r.write, func(ctx context.Context, tx *sql.Tx) error {
_, ierr := tx.ExecContext(ctx, sqlInsertItem, it.ID, it.Name, it.Quantity, it.CreatedAt)
return mapWriteError(ierr, it.Name)
})
if err != nil {
return fmt.Errorf("failed inserting item: %w", err)
}
return nil
}
The comment does something a code comment rarely does: it admits that the code is larger than the problem. One statement is already atomic. The
shape is there because the second statement arrives eventually, and
page 12 covered what Exec guarantees when it
does: commit on a nil return, rollback on anything else, rollback in a defer
so a panic does not leave the transaction open, sql.ErrTxDone ignored, and a
rollback failure joined onto the error already there rather than replacing it.
The write goes to r.write. Reads go to r.read, which points at a replica, so
the two connections from page 12 are separated at the one place that knows which
statement is which.
The unique index, not a lookup
const mysqlErrDupEntry = 1062 // raised by a violation of the uk_item_name unique index
// mapWriteError translates a MySQL duplicate-key failure into ErrConflict, so
// the unique index is reported as a conflict instead of an unknown failure.
func mapWriteError(err error, name string) error {
if err == nil {
return nil
}
var myerr *mysql.MySQLError
if errors.As(err, &myerr) && myerr.Number == mysqlErrDupEntry {
return fmt.Errorf("%w: %s", ErrConflict, name)
}
return err
}
There is no “does this name exist” query anywhere in the feature, and adding one would not help. A check and an insert are two statements, another request fits between them, and both would then insert. The constraint is the only thing that decides, because it is evaluated by the one component that sees both writes.
errors.As rather than a string match on the driver’s message, and a named
constant rather than 1062 at the call site. errors.As unwraps through the
repository’s own %w and through sqltransaction’s, so the number survives
however many layers added context on the way out.
The file says what this costs:
// This repository targets MySQL: the "?" placeholders and the duplicate-key
// mapping below are driver-specific (Postgres uses "$1" and its own SQLSTATE).
// A service supporting both engines needs a placeholder strategy and one error
// mapping per driver; this example supports one engine and says so.
Worth copying as a habit. A repository that silently assumes one database is a surprise for whoever ports it; one that says which is a decision.
Answering
// internal/httphandlerpub/item.go
// sendItemError maps the item sentinel errors to HTTP status codes.
//
// Anything unrecognized is logged and answered with a 500 whose body says
// nothing, so an internal failure is diagnosable without being exposed.
func (h *HTTPHandlerPublic) sendItemError(ctx context.Context, w http.ResponseWriter, err error) {
switch {
case errors.Is(err, item.ErrNotFound):
h.httpres.SendStatus(ctx, w, http.StatusNotFound)
case errors.Is(err, item.ErrConflict):
h.httpres.SendStatus(ctx, w, http.StatusConflict)
case errors.Is(err, item.ErrValidation):
h.httpres.SendStatus(ctx, w, http.StatusUnprocessableEntity)
default:
h.logger.With(slog.Any("error", err)).ErrorContext(ctx, "item request failed")
h.httpres.SendStatus(ctx, w, http.StatusInternalServerError)
}
}
Page 10’s mapping, in twelve lines, at the one place every item handler funnels its errors through. Three sentinels, three codes, and a default branch that is the one to read twice: the wrapped chain goes to the log with its table names and its DSN fragments, and the body gets the status text and nothing else.
Every handler calls this rather than deciding for itself, so the endpoint added
next month cannot map ErrNotFound to a 400 by accident.
The success path:
w.Header().Set("Location", "/items/"+it.ID)
h.httpres.SendJSON(ctx, w, http.StatusCreated, it)
Location because the client did not choose the identifier and has no other way
to learn where the thing it created now lives. The body carries the full item as
well, which saves the round trip a bare 201 would force.
Delete is the one response that does not go through the writer:
// A 204 response carries no body, so the status is written directly instead
// of through SendStatus, which writes the reason phrase as the body.
w.WriteHeader(http.StatusNoContent)
SendStatus writes the status text as a text/plain body, right for an error and wrong for a 204, where RFC 9110 says there is no body at all. Some
clients read the Content-Length, some stop at the status line, and the ones
that read get a body the specification told them does not exist.
What the example leaves out
None of what follows is in the item feature. Each is a mechanism a write path grows into, and each is cheaper to add on purpose than to retrofit during an incident.
Partial updates
The item feature has no PATCH, so the hard part of one is never reached:
telling “the client did not mention this field” from “the client explicitly set
it to null”.
With a nullable field decoded into a *string, both an omitted key and an
explicit "notes": null produce nil. Same value, opposite intentions. The
answer is one more level of indirection, a **string, where the outer pointer
says whether the key was present and the inner one is the value:
| Request body | Field value | Meaning |
|---|---|---|
{} | nil | Absent: keep the current value |
{"notes": null} | non-nil, pointing at nil | Clear it |
{"notes": "vip"} | non-nil, pointing at "vip" | Set it |
A double pointer is not pretty, and the alternatives cost more: json.RawMessage
per field decodes twice, a map[string]any alongside the struct loses the
types, and a field mask moves the bookkeeping to the client. For one nullable
field it is contained; at eight, a small generic Optional[T] with Set and
Value is worth writing. Test both cases, because it looks right when it is
not: new(*string) is a non-nil pointer to a nil *string, exactly what the decoder produces for an explicit null.
Optimistic concurrency
Two clients read the same row, both change it, both write, and the second write silently overwrites the first while the first client believes their change took effect. A lost update is undetectable from either side.
The fix is a version column that the update matches on:
UPDATE item SET name = ?, quantity = ?, version = version + 1
WHERE id = ? AND version = ?
No lock is held between the client’s read and this write. A concurrent update
that already advanced the version matches no row, RowsAffected() is zero, and
that is the detection mechanism. The client is told it lost, re-reads, re-applies
and retries.
The version travels to the client as an ETag and comes back in If-Match,
which puts three status codes in play: 428 when the header is absent and the
service refuses unconditional updates, 412 when it is present and unusable, and
409 when it was valid and the version had moved. RFC 9110 permits 412 for the
last one too; the split worth defending is that 412 is about the request and 409
is about the state.
Idempotency keys
A client sends POST /items, the network drops the response, and they cannot
tell whether the item was created. Retrying might create a second one. Here the unique index on name happens to prevent that, by luck rather than design: a resource without a natural unique key has nothing standing in the way.
An Idempotency-Key header fixes it when three things are true. The key, a
fingerprint of the request and the resulting identifier are written in the same
transaction as the mutation, because writing them separately leaves a window
where the row exists and the key does not. There is a unique constraint on the
key, because two simultaneous retries both find nothing on lookup and the
constraint is what decides between them. And a replay carrying the same key with
a different body is a 409 rather than a silent success, because the guarantee
the client relied on cannot be given for a request they have changed.
The fingerprint is computed from the decoded payload rather than the raw bytes, so reordered keys and different whitespace still match. The key table grows and needs expiring, which is the scheduled job on page 20 and a retention window a client has to be told about.