Docs / ArchitectureKit 2.0 / 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 int or any other integer type an integer, and a float64 or a float32 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, a json.Number a number, unless the string option makes it a string, and a type with a MarshalText or an AppendText function a string. An interface allows any value, even if it declares a Schema function. So does a json.RawMessage, which is the same type as jsontext.Value of encoding/json/v2, since encoding/json writes the JSON it holds, or null if it is nil.

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.

Note that EventSourcingDB currently reads every number in the data of an event as a 64-bit floating-point number, which holds an integer exactly only if its magnitude is at most 2^53 - 1, which is 9007199254740991. A larger integer is stored changed, without an error: 9007199254740993 comes back as 9007199254740992, and a time in nanoseconds, as UnixNano returns it, loses its last digits. Close to the largest int64 or uint64, the stored number may even lie beyond the range of the type, so that the event can not be read back any more, and Load, Execute, and a projection fail with an error of the category ErrPermanent. To keep a 64-bit integer exact, give its field the string option of the json tag, as in json:"atNanos,string", which writes it as a string.

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, borrowedUntil accepts any string so far. To make sure that it is a date, BookBorrowed could use a Date type 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"`
}

Note that the other examples in this documentation keep BorrowedUntil a string. With the Date type, they would convert between the two, as in Date(cmd.BorrowedUntil).

Apart from time.Time, json.Number and json.RawMessage, a type that encodes itself with a MarshalJSON function, or with MarshalJSONTo of encoding/json/v2, which encoding/json calls as well, needs such a Schema function, too, since the kit can not know what the function writes. So does a type that has its MarshalText or AppendText function on a pointer receiver only: encoding/json calls it only for a value it can take the address of, so whether such a value is written as a string depends on where it is. If the schema of an event can not be derived, for example because of such a type, a recursive type, a channel, a function, or a complex number, Evolve or Ignore panics and names the field.

Note that the derived schema never takes over the Schema function of an interface. encoding/json writes the value a field of the interface holds, or null, so there is no value whose Schema function could be asked, and the field is described as if the interface had none. To constrain such a field, or one whose interface encodes itself with MarshalJSON, give the event a Schema function of its own.

Note that a field of type json.RawMessage is constrained the same way, with a Schema function of the event. A type declared from json.RawMessage, as in type Details json.RawMessage, does not help, since it does not take over the MarshalJSON function: encoding/json writes such a value as a []byte, which is a string.

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, arrays as []any, and numbers as float64, such as the minItems of an array, as encoding/json decodes them.

A Schema function describes the type that declares it. Go also promotes it to a struct that embeds the type, but there it still describes the embedded type alone, while encoding/json writes the fields of the embedded struct next to the other fields of the struct. So if an event, or the type of a field, has its Schema function only from an embedded field, Evolve or Ignore panics and names the embedded field, and so does DeriveSchema for such a type. Give the struct a Schema function of its own, or make the embedded type a named field.

Note that the derived schema describes the fields of an embedded struct by their own types, as it describes all other fields, even if the embedded struct has a Schema function. A struct that starts its own Schema function from DeriveSchema therefore does not get the constraints from the Schema function of the embedded struct. Add them there if the struct needs them.

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

Keeping Schemas in Files#

An event's own schema does not have to be written in Go. To keep it in a JSON file instead, embed the file with the embed package, and decode it in the Schema function of the event:

import (
  _ "embed"
  "encoding/json"
)

//go:embed schemas/book-acquired.json
var bookAcquiredSchema []byte

func (BookAcquired) Schema() map[string]any {
  var schema map[string]any
  if err := json.Unmarshal(bookAcquiredSchema, &schema); err != nil {
    panic(err)
  }

  return schema
}

The Evolve and Ignore functions call Schema when the state is built, so a file that does not contain valid JSON stops the application on start, before it writes anything.