Skip to content

Versioning & evolution

A bounded context is a contract. The moment another team subscribes to your integration events or shares one of your kernel types, every change you make can quietly break them. Koine makes that contract explicit: you stamp a context with a version, annotate when members arrived (@since) or fell out of favour (@deprecated), and run koine check --baseline to catch breaking changes before they ship.

This is Koine’s model-versioning support. None of it changes your runtime behaviour — version and @since are pure metadata that surface in the glossary and drive diagnostics; only @deprecated emits anything to C# (an [Obsolete] attribute).

Two grammar constructs carry versioning and evolution metadata: the optional version clause on a context declaration, and the annotation prefix that may appear on any type or field declaration.

context_decl
: 'context' Identifier ( 'version' IntLiteral )? '{' context_member* '}'
;
annotation
: AT Identifier ( '(' ( IntLiteral | StringLiteral ) ')' )?
;
type_decl
: value_decl | quantity_decl | entity_decl | aggregate_decl
| enum_decl | event_decl | integration_event_decl
;
value_declaration
: annotation* 'value' Identifier '{' member* '}'
;
member
: annotation* Identifier ':' type_ref ( '=' expression )?
;

The version clause is an optional 'version' IntLiteral between the context name and its opening brace. The integer is a bare, non-negative literal with no parentheses. Omitting the clause leaves the context unversioned.

An annotation is a @ sign followed by a bare identifier, with an optional single argument in parentheses: an integer for @since, a string for @deprecated. Unknown annotation names are silently ignored; only since and deprecated are acted on by the compiler. Because AT (@) is the annotation prefix, since and deprecated are not reserved as keywords — they may appear freely as field names.

type_decl is a bare dispatcher — it carries no annotation* of its own. Each individual declaration rule (shown above for value_declaration; the same pattern applies to quantity_decl, entity_decl, aggregate_decl, enum_decl, event_decl, and integration_event_decl) begins with its own leading annotation* before the declaration keyword. Likewise, a member rule carries its own leading annotation* before the field name. This is where @since and @deprecated actually attach.

context Catalog version 2 {
value Product {
name: String
price: Decimal
// A field introduced in v2 of the context.
@since(2) barcode: String?
}
// A whole type added in v2.
@since(2) value Promotion { code: String }
}

The version IntLiteral clause sets the context’s declared version. The version:

  • becomes the glossary heading (## Catalog — version 2), and
  • sets the ceiling for the @since ceiling check (§18.3.2).

The version does not leak into the generated C#. Catalog/Currency.cs is byte-for-byte identical whether or not you stamp a version.

@since(n) records the context version in which a declaration first appeared. It may prefix a field name or the declaration keyword for a whole type.

A @since(n) whose n is greater than the context’s declared version is almost always a typo — you are claiming a member arrived in a version that does not exist yet. Koine warns:

context Sales version 1 {
value Money {
amount: Decimal
@since(5) bonus: Decimal // KOI1501: @since(5) > version 1
}
}
sales.koi:4:5: warning KOI1501: Field 'bonus' is annotated @since(5) but context 'Sales' is only version 1.

The message names the exact member and the mismatched versions. A type-level @since above the ceiling warns the same way ('Promotion' is annotated @since(7) but context 'Catalog' is only version 2.).

@deprecated("reason") marks a field or type as obsolete. The reason argument is a string literal. Unlike @since, @deprecated is not a pure-metadata annotation — it reaches the emitter and produces a C# [Obsolete] attribute (see §18.4).

@deprecated works on every published shape too — including integration-event (§17) fields.

version, since, and deprecated are not reserved — only @ + identifier forms an annotation, so value Tag { version: Int since: Int deprecated: String } is a perfectly valid record with three ordinary fields. Annotation names other than since/deprecated (and arg types other than the expected Int/String) are silently ignored.

@since(n) emits no C# attribute. It sets the declaration’s Since metadata, which the glossary renders as a suffix on the member or type:

MemberType
barcodeString?(since v2)

@deprecated is the one annotation that reaches C#. It prefixes a field or a type and renders as [Obsolete]:

context Sales {
value Money {
amount: Decimal
@deprecated("use amount") legacyAmount: Decimal
}
@deprecated("use Money") value OldMoney { amount: Decimal }
}

emits, on the property and the class respectively:

[Obsolete("use amount")]
public decimal LegacyAmount { get; }
[Obsolete("use Money")]
public sealed class OldMoney { /* ... */ }

The compiler injects using System; into any file that gains an [Obsolete], and C#-escapes quotes in the reason. A model with no @deprecated annotations gets neither the attribute nor the extra using.

@deprecated works on integration-event fields too:

context Sales {
publishes OrderPlaced
integration event OrderPlaced {
orderId: OrderId
total: Decimal
@deprecated("use total") legacyAmount: Decimal
}
}

Annotations document intent; koine check enforces it. Point it at your current model and a previously published baseline directory, and it diffs the two models’ published surfaces and exits non-zero on any breaking change:

Terminal window
koine check ./current --baseline ./published

Both arguments may be a single .koi file or a directory (a directory compiles every .koi under it as one model). Three kinds of surface are compared — everything else (internal value objects, entities, aggregates) is ignored:

Published surfaceHow it becomes published
Integration eventspublishes <Name> + integration event <Name> { … }
Shared-kernel typesa shared-kernel { T … } relation in the context map (§17)
Open-host value objectsan open-host / published-language relation where the context is upstream

The diff classifies each change. A breaking change carries a KOI code and fails the build; additive changes are reported as informational and pass.

ChangeVerdictCode
Published type removedbreakingKOI1510
Published record field removedbreakingKOI1511
Published field’s type changedbreakingKOI1512
Optional field made required (T? → T)breakingKOI1513
New required field addedbreakingKOI1514
Published field renamed (same shape, new name)breakingKOI1515
Published enum value removedbreakingKOI1516
Integration-event payload shape changed (a breaking add/remove/retype)breakingKOI1517
New optional field added (T?)non-breaking—
New event / new type addednon-breaking—
New enum value addednon-breaking—

Optionality is the ? type suffix: note: String? is optional and additive; note: String is required.

A rename (KOI1515) is detected when a removed field and an added field share the same shape (type, ignoring nullability) and optionality — so total: Decimal → amount: Decimal reports a single rename rather than a separate remove + add. An integration event is a wire contract, so any breaking payload change additionally reports an event-level shape-change summary (KOI1517) alongside the per-field code.

The default verdicts above are a policy, not a law. A koine.config can override the impact of any code with a check.severity.<CODE> key — Breaking, NonBreaking, or Ignored:

# koine.config — relax the rename rule for this repo.
check.severity.KOI1515 = NonBreaking # a rename no longer fails the gate
check.severity.KOI1512 = Ignored # drop type-change reports entirely

NonBreaking downgrades a change so it no longer trips the exit code; Ignored drops it from the report altogether; Breaking (re)promotes one. Codes with no override keep their default verdict.

Take a v1 baseline that publishes an order-placed contract:

context Sales version 1 {
integration event OrderPlaced {
orderId: OrderId
total: Decimal
coupon: String
}
publishes OrderPlaced
}

Evolve it to v2: add an optional note (fine) but drop coupon (not fine):

context Sales version 2 {
integration event OrderPlaced {
orderId: OrderId
total: Decimal
// coupon removed -> BREAKING
@since(2) note: String? // added optional field -> backward-compatible
}
publishes OrderPlaced
}

Running the check reports both changes and fails on the removal:

Terminal window
koine check examples/versioning/v2 --baseline examples/versioning/v1
breaking KOI1511: field 'coupon' of published integration event 'OrderPlaced' was removed.
non-breaking: field 'note' of published integration event 'OrderPlaced' was added.
error: 1 breaking change(s) to published surfaces

Reverse the comparison — i.e. treat v2 as the baseline and v1 as the new model — and you see the mirror image: re-adding coupon as a required field is KOI1514, and dropping the optional note is KOI1511.

18.5.5 Publishing a surface via the context map

Section titled “18.5.5 Publishing a surface via the context map”

A field change is only breaking if the type is actually published. An integration event declared with publishes is published by definition. To put a value object under the same scrutiny, give it a published relation in the context map (§17):

context Sales {
value Money { amount: Decimal currency: String }
}
context Billing { }
contextmap {
// Money is now a shared-kernel contract owned with Billing.
Sales <-> Billing : shared-kernel { Money }
}

Now removing Money.currency, changing its type, or making an optional field required all become breaking changes that koine check will catch. An open-host relation (Sales -> Shipping : open-host) publishes Sales’ value objects the same way.

  • Context maps & integration events (§17) — the published surfaces: integration events, shared-kernel, and open-host relations.
  • Aggregates (§7) — the versioned keyword for optimistic concurrency on an aggregate root, which is distinct from the context version clause.
  • Contexts & types (§4) — context declarations and their members.
  • Evolving a model — a step-by-step walkthrough of versioning a context and running the baseline check.
  • CLI reference — full koine build and koine check flags, plus the --glossary output where version, @since, and @deprecated surface for humans.