Versioning and Compatibility
What actually breaks a client, why additive change is the default, and when a /v2 prefix is worth it.
mux.Handle("/v1/", http.StripPrefix("/v1", v1Routes(svc)))A client is any program that calls your API: a mobile app on someone's phone, another team's service, a script in a cron job. You cannot redeploy them. Versioning is the discipline of knowing which changes are safe to ship and which ones need a new version alongside the old.
What breaks a client
Removing or renaming a response field. Changing a field's type, for example an id from number to string. Making an optional request field required. Rejecting input you used to accept. Changing which status code an outcome returns. Changing the default page size or sort order, if a client depends on it. All of these break code that was working this morning.
Additive by default
Adding a new field, a new optional request field or a whole new endpoint is safe for any client that ignores what it does not recognise. Go's encoding/json does ignore unknown fields when decoding, unless the client calls DisallowUnknownFields. So treat the rule as "add, never remove". To rename, add the new field, keep filling the old one, and delete it only once nothing reads it.
When you need v2
Some changes cannot be additive, such as splitting one field into three. Then mount a second version — commonly a path prefix like /v1/ and /v2/ — and have both call the same service layer. Only the conversion at the edge differs. Announce a removal date for v1 and measure its traffic before switching it off.
Example
package main
import (
"encoding/json"
"fmt"
)
// noteV2 is today's response shape. The tags field was added after launch.
type noteV2 struct {
ID string `json:"id"`
Text string `json:"text"`
Tags []string `json:"tags"`
}
// clientNote is the shape an old client still decodes into. It has never
// heard of tags, and it was compiled months before tags existed.
type clientNote struct {
ID string `json:"id"`
Text string `json:"text"`
}
func main() {
body, err := json.Marshal(noteV2{ID: "1", Text: "buy milk", Tags: []string{"home"}})
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(body))
var old clientNote
if err := json.Unmarshal(body, &old); err != nil {
fmt.Println(err)
return
}
// Unknown fields are skipped, so the added field breaks nothing. This is
// why adding a field needs no new version and removing one does.
fmt.Printf("%+v\n", old)
}
// Output:
// {"id":"1","text":"buy milk","tags":["home"]}
// {ID:1 Text:buy milk}When to use it
- Adding a field to a response without waiting for a mobile release.
- Deciding whether a validation tightening needs /v2 or can ship as is.
- Running v1 and v2 side by side while a partner migrates off the old shape.
More examples
Two versions, one service
Versioning lives in the handler layer only. There is no v1 service and no v1 store, so a bug fix helps both versions at once and the duplication is limited to a struct and a few assignments. A dedicated reply struct per version is what stops a new service field from leaking into an old contract.
func routes(svc Service) http.Handler {
mux := http.NewServeMux()
// v1 keeps the exact shape its clients already parse.
mux.HandleFunc("GET /v1/notes/{id}", v1Get(svc))
// v2 may use a different shape. Both call the same service.
mux.HandleFunc("GET /v2/notes/{id}", v2Get(svc))
return mux
}
// v1Get returns a handler that closes over the service, which is how you give
// a handler a dependency without hanging it off a struct.
func v1Get(svc Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
n, err := svc.Find(r.Context(), r.PathValue("id"))
if err != nil {
writeError(w, err)
return
}
// The v1 reply type is frozen: whatever the service grows, this
// struct keeps sending exactly the fields v1 promised.
writeJSON(w, http.StatusOK, noteV1{ID: n.ID, Text: n.Text})
}
}Renaming additively
Sending the same value under two keys for a few months is cheap and needs no v2 at all. Write the deprecation and the removal condition in the comment and in your API docs on the day you add the new field, otherwise the old one lives forever because nobody remembers why it is there.
type noteReply struct {
ID string `json:"id"`
// Text is the old name. It is deprecated and still filled in, because
// clients released before Body existed read it. Remove it only when the
// metrics show nobody fetches with an old client any more.
Text string `json:"text"`
// Body is the new name. New clients read this one.
Body string `json:"body"`
}
func replyFor(n Note) noteReply {
return noteReply{ID: n.ID, Text: n.Content, Body: n.Content}
}Two changes that look harmless and are not
Use DisallowUnknownFields on requests you receive, where rejecting a typo is a feature, and not on responses you consume, where it turns a supplier's additive change into an outage. The omitempty case is worth checking before every release: it is a one-word edit with a wire-visible effect.
// Before: tags is always a key. [] when the slice is empty but not nil,
// null when the slice is nil.
type v1 struct {
Tags []string `json:"tags"`
}
// After: omitempty drops the key entirely for an empty or nil slice, so a
// client doing data.tags.length now fails on a note with no tags.
type v2 struct {
Tags []string `json:"tags,omitempty"`
}
// Also breaking, and easy to ship by accident: a client that decodes with
// DisallowUnknownFields rejects the whole response the moment you add a
// field. Document that clients must tolerate unknown fields.
dec := json.NewDecoder(resp.Body)
dec.DisallowUnknownFields()
Discussion