Skip to content

Stored Shapes

A type which is written to disk is a promise. An event is replayed from the log forever, a JSON document is read back by the next release. If a field is renamed or removed, the code still compiles and the tests still pass, because they only see new data. What breaks is the data written before, and you notice in production.

speclink therefore treats every persisted type as frozen by default and reports incompatible changes as errors. This page explains what counts as persisted, what you may change, and how a shape grows.

What is persisted

Two kinds of types, and only these:

TypeWhy it is persisted
an eventit implements Evolve and Discriminator; the struct is the stored form
a persistence modelthe type a JSON repository was built over

Nothing in a struct says that it is stored; the repository constructor does. Nago offers two:

// Two models, mapped. Only CustomerEntity is promised; Customer stays free to change.
json.NewJSONRepository[Customer, CustomerID, CustomerEntity, CustomerID](store, intoDomain, intoPersistence)

// One model. The domain type IS the stored form and is promised as it stands.
json.NewSloppyJSONRepository[Book, BookID](store)

With the sloppy form every rename in the domain type is a change to stored data. tutorial-113 uses it because it is a small example. For anything which outlives a prototype, prefer the mapped form: the domain model can then be restructured without touching a byte on disk.

Drafts

While a type is still in development, mark it as a draft in an annotation file. Everything persisted is frozen unless it is marked:

var _ = spec.ForPackage(spec.Draft())          // every persisted type of the package
var _ = spec.For[QuoteWithdrawn](spec.Draft()) // one type
var _ = spec.ForField[Quote]("Note", spec.Draft()) // one field

spec.Draft() means one thing: we are willing to delete every stored value of this type. Remove it as soon as nobody would do that any more. That moment has nothing to do with going live; a development database can already hold data somebody minds losing.

What you may change once frozen

ChangeAllowed?
change the discriminator of an eventno, it orphans every stored message
two persisted types with the same discriminatorno, not even as drafts
remove a fieldno
change the stored name (JSON tag) of a fieldno; renaming the Go field is fine
change a field’s shape incompatibly, e.g. int to stringno
string to a named string type, or another integer widthyes
add a fieldyes, marked spec.Optional()
make an optional field required againno

A new field must be optional, because values written before it existed do not carry it:

var _ = spec.ForField[QuoteSubmitted]("Channel",
	spec.Optional(),
)

speclink.lock

The current source cannot tell what a field used to be. The promise is therefore recorded in speclink.lock, written by speclink freeze and never edited by hand, similar to go.sum. Committing to a shape is two steps: delete the spec.Draft() term, then run

speclink freeze -n ./...   # show what would be recorded
speclink freeze ./...      # record it

The diff of speclink.lock in the merge request is what a reviewer reads. freeze refuses to record a shape which already breaks a promise, and a recorded shape cannot be turned back into a draft.

Related