Input Validation

Every endpoint’s inputs cross a trust boundary. A functional endpoint’s arguments arrive from another microservice, an LLM picking tool arguments, or an internet client through the ingress — and the handler is the first code that assumes they make sense.

Microbus validates declaratively at that boundary, using dv8 directives written as struct tags. The generated marshaler runs them right after decoding, so an invalid payload is rejected 400 Bad Request and the handler never sees it.

Validation is opt-in per type: it fires only on api types that carry dv8 tags, so a project that adds none sees no change in behavior.

Declaring Constraints

Constraints live on the fields of a type in the microservice’s *api package, alongside the json and jsonschema_description tags:

package myserviceapi

// Person is a contact in the directory.
type Person struct {
	Name  string `json:"name,omitzero" jsonschema_description:"Name is the person's full name" dv8:"notzero,len<=64"`
	Email string `json:"email,omitzero" jsonschema_description:"Email is the person's email address" dv8:"trim,tolower,regexp ^.+@.+$"`
	Age   int    `json:"age,omitzero" jsonschema_description:"Age is the person's age in years" dv8:"val>=0,val<=120"`
}

The directives used most often:

DirectiveApplies to
notzeroRequires a non-zero value — non-nil for pointers, slices and maps; true for booleans
len<=64, len>0Length of a string (in runes), slice, or map
val>=0, val<=120Value range for numeric, duration and time fields
oneof S|M|LMembership in a set
regexp ^[0-9]{5}$Pattern match; a comma inside the pattern is escaped \,
trim, tolower, toupper, default=xNormalizing directives that rewrite the value before the checks run
each <directive>Applies a directive to the elements of a slice or the values of a map
key <directive>Applies a directive to the keys of a map

dv8’s own README carries the full set.

Where the Directives Are Enforced

  • In structs of functions, web endpoints and inbound events — validated after decoding, before the handler runs.
  • Task In structs — validated the same way, and deliberately so. A workflow’s state is a cross-service contract populated by callers, by LLMs, and by tasks hosted in other microservices, so a violated contract should fail the flow at the offending step rather than propagate downstream and fail somewhere less informative.
  • Structured configs — validated on the way in and rejected before being committed, as Configuration describes.
  • Out structs — tags are inert. Outputs are produced by trusted code and are not validated.
  • Direct Go calls — a handler calling another handler in-process bypasses the framework boundary entirely. Where such internal composition needs validation, call dv8.Validate(ctx, &v) explicitly.

Directives are compiled at startup, over every input type the microservice declares. A malformed directive — a typo, a rule applied to a type it cannot work on — fails the microservice immediately rather than lying dormant until the first request that exercises that field.

Cross-Field Rules: the Validate Method

Invariants that span more than one field cannot be expressed as a per-field tag. Give the type a Validate method instead, and the framework calls it automatically wherever the type is validated, including when it is nested inside another validated type:

// Validate errors when the person record is internally inconsistent.
func (p *Person) Validate(ctx context.Context) error {
	if p.Retired && p.Age < 50 {
		return errors.New("retirement age is 50")
	}
	return nil
}

The signature is Validate(ctx context.Context) error, or Validate() error where the context is unused — a type implements one or the other, never both.

The method must be a pure check of its fields: no I/O, no downstream calls, no state mutation. It runs on the startup path and on every config refresh, so a validator that calls out turns validation into a distributed dependency. It also runs after the tag directives, so the values it sees are already trimmed and defaulted.

Constraints Reach the OpenAPI Document

The same directives are projected onto the rendered OpenAPI schema, so a constraint stated once reaches both the runtime and every consumer of the document — human readers and LLM tool-calling above all, which otherwise discover the contract by collecting 400s.

len<=64 becomes maxLength, val>=0 becomes minimum, oneof becomes enum, regexp becomes pattern, notzero adds the field to the parent’s required, default= becomes default, and the each / key prefixes descend into items / additionalProperties / propertyNames.

The projection is deliberately looser or equal to what the runtime enforces, so a spec-strict client never rejects a request the server would have accepted. Directives with no schema analog — the mutating ones like trim, and structural ones — are simply skipped, as are numeric bounds whose value is not a JSON number, such as a duration written 24h.

Further Reading

  • Configuration — validating structured config values with the same directives.
  • Agent Skills — the add-type skill, which writes an api type with its tags and optional Validate method.
  • OpenAPI — the generated document these constraints are projected onto.