Architecture: consistency and evolution · 3 MIN READ
Evolve APIs, events and schemas across mixed deployments
Use expand–migrate–contract changes while old and new Go processes coexist.
A rolling deployment is a period with multiple versions, not an instantaneous replacement. The database, producers, consumers and cached clients may all run different code. An additive change is useful only if old readers tolerate it and the new writer does not destroy information they still need. Plan compatibility as a sequence with observable exit conditions.
system-design · A MAP OF THE IDEA
- Expand readers
- Change writers
- Migrate + measure
- Contract later
Useful when
- Use expand–migrate–contract changes while old and new Go processes coexist.
- Makes the owner and the failure boundary explicit.
Watch out
- A rename performed as drop-old/add-new in one release breaks old binaries during rollout and rollback.
- Run native service and failure tests in your own development environment.
Implementation & operation cost: Additional state and tests must be justified by the demonstrated failure; this lesson is not a requirement for every Go program.
The contract and the failure
A rolling deployment is a period with multiple versions, not an instantaneous replacement. The database, producers, consumers and cached clients may all run different code. An additive change is useful only if old readers tolerate it and the new writer does not destroy information they still need. Plan compatibility as a sequence with observable exit conditions.
Work through the example
First deploy readers that understand both shapes. Then introduce the new field and migrate stored data with a restartable cursor. Measure old-field use before removing it. Give events explicit schema contracts and keep captured old payloads as fixtures. Review semantic changes too: changing units, default timeouts or authorization can break a client without changing a Go type.
package main
import (
"encoding/json"
"fmt"
)
type Wire struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
}
func name(w Wire) string {
if w.DisplayName != "" {
return w.DisplayName
}
return w.Name
}
func main() {
for _, raw := range []string{`{"name":"Ada"}`, `{"name":"Ada","display_name":"A. Lovelace"}`} {
var w Wire
if err := json.Unmarshal([]byte(raw), &w); err != nil {
panic(err)
}
fmt.Println(name(w))
}
}The new reader accepts the old name field and prefers the additive display_name field when present. This is a migration model, not a universal rule to accept arbitrary input: strict external API validation and backward-compatible internal event readers can require different policies. Presence and an empty string may need a pointer or explicit optional type.
Production decisions
A rename performed as drop-old/add-new in one release breaks old binaries during rollout and rollback. Avoid dual writes without a stated source of truth and reconciliation policy. Database index creation and long backfills also need operational budgets. Test old writer/new reader, new writer/old reader, rollback and replay of retained events before declaring the migration complete.
Explain it in an interview
An old worker may run for another hour. When may the old schema field be removed? After compatible readers are deployed, data is migrated and evidence shows old consumers no longer require it; rollback and event replay must still work. Describe the state before the operation, its owner, the failure between steps and the evidence you would test. Distinguish what this example demonstrates from the infrastructure a production service still needs.
TAKE THIS WITH YOU
Use expand–migrate–contract changes while old and new Go processes coexist. After compatible readers are deployed, data is migrated and evidence shows old consumers no longer require it; rollback and event replay must still work.
Check your understanding
An old worker may run for another hour. When may the old schema field be removed?
Go to the source
Progress stays in this browser. No account needed.