Docs / ArchitectureKit / Guides / Describing Events with Schemas

Describing Events with Schemas

Every event type has a JSON schema, which the database checks every event of the type against, once the schema is registered (see Registering Event Schemas). The kit derives it from the struct, so that it describes exactly what encoding/json writes for the event:

  • A struct is an object with the fields encoding/json writes: named by their json tags, without fields tagged - and unexported ones, and with the fields of embedded structs in place of the embedded struct. Fields with omitempty or omitzero, and fields of an embedded pointer, are optional, all others are required, and no other fields are allowed.
  • A string is a string, a bool a boolean, an integer an integer, and a floating-point number a number. The string option of a json tag turns such a field into a string.
  • A slice is an array, a []byte a string, and an array an array of exactly its length. A map is an object whose values all have the same schema.
  • A pointer, a slice and a map may also be null, since encoding/json writes null for nil, unless a field of such a type is optional and therefore left out instead.
  • A time.Time is a string in the date-time format, and a type with a MarshalText function a string. An interface allows any value.

For BookBorrowed, this yields the following schema:

{
  "type": "object",
  "properties": {
    "borrowedBy": { "type": "string" },
    "borrowedUntil": { "type": "string" }
  },
  "required": ["borrowedBy", "borrowedUntil"],
  "additionalProperties": false
}

These rules do not change, since a registered schema can not change either.

To constrain a value further than its Go type does, declare a type for it with a Schema function, which returns the JSON schema of the type. Wherever a field has that type, the derived schema takes it over. For example, to make sure that borrowedUntil is a date, declare a Date type and use it for the field:

type Date string

func (Date) Schema() map[string]any {
  return map[string]any{"type": "string", "format": "date"}
}

type BookBorrowed struct {
  BorrowedBy    string `json:"borrowedBy"`
  BorrowedUntil Date   `json:"borrowedUntil"`
}

A type that encodes itself with a MarshalJSON function needs such a Schema function, too, since the kit can not know what the function writes. If the schema of an event can not be derived, for example because of such a type, a recursive type, or a channel, Evolve panics and names the field.

If an event needs a schema that its fields can not express, give the event itself a Schema function. It takes precedence over the derived schema. To start from the derived schema, call the DeriveSchema function, which derives the schema of a type without calling its own Schema function. For example, to require at least one of two optional fields:

type BookCorrected struct {
  Title  string `json:"title,omitempty"`
  Author string `json:"author,omitempty"`
}

func (BookCorrected) EventType() string {
  return "io.eventsourcingdb.library.book-corrected"
}

func (BookCorrected) Schema() map[string]any {
  schema := architecturekit.DeriveSchema[BookCorrected]()
  schema["minProperties"] = 1

  return schema
}

DeriveSchema returns a new schema on every call, with objects as map[string]any and arrays as []any, as encoding/json decodes them.

Note that a json tag name that encoding/json considers invalid also makes Evolve panic, since encoding/json reads such a name differently depending on the Go version the application declares.