Invariants
10.1 General
Section titled “10.1 General”An invariant is a rule that must hold for an object to exist. Koine compiles each
one into a guard at the top of the constructor (and re-checks them after a
command mutates state). If a guard fails, the object is never created and a
DomainInvariantViolationException is thrown.
That means you can never get your hands on an invalid Sku, Money, or Order — the
type system and the constructor enforce it. There is no separate “validate then use”
step; validity is a property of having an instance.
Invariants are valid inside value, quantity, entity, and aggregate roots. The
member order in a body is fixed: fields first, then invariants, then any states,
commands, and factories.
10.2 Syntax
Section titled “10.2 Syntax”An invariant is declared with the invariant keyword followed by a boolean expression
and an optional failure-message string. The expression may use the full Koine expression
language, including the when guard form and the matches regex form:
invariant : 'invariant' expression StringLiteral? ;The expression grammar — including the when guard and matches — is specified in Expressions §9.2.
An invariant consists of:
- The keyword
invariant. - An
expression— any well-formed boolean expression from the expression language (Expressions (§9)). This includes comparisons, logical operators (&&/||/!), string operations, collection operations (all,count,sum,distinctBy), and the regex-match form<expr> matches /pattern/. - An optional
StringLiteralthat becomes the failure message surfaced inDomainInvariantViolationException.Rule. When omitted (as with thewhenform and spec-backed invariants) Koine synthesizes the rule text from the source.
The when guard and the matches operator are both part of the full expression grammar
defined in Expressions (§9). Writing
invariant <body> when <cond> makes the whole guard conditional on <cond>, while
<field> matches /pattern/ switches the lexer into regex mode so the /…/ is read as a
single token rather than two division operators.
value Price { amount: Decimal currency: Currency invariant amount >= 0 "a price cannot be negative"}10.3 Semantics
Section titled “10.3 Semantics”10.3.1 Evaluation order and scope
Section titled “10.3.1 Evaluation order and scope”Every invariant declared on a type is evaluated in declaration order at the top of the
constructor, before any field assignments. An invariant expression may reference any
field of the type (including derived fields whose expression does not itself depend on
uninitialized state) and the full expression language.
10.3.2 Message synthesis
Section titled “10.3.2 Message synthesis”When a StringLiteral message is present, it becomes the rule argument of
DomainInvariantViolationException. When the message is omitted — as with when-guarded
invariants and spec-backed invariants — Koine synthesizes the rule text from the source
representation of the invariant body.
10.3.3 Satisfiability analysis
Section titled “10.3.3 Satisfiability analysis”The compiler statically folds the constant parts of each value object’s invariants and flags ones that can never hold — a value object whose invariants contradict each other can never be constructed, so the generated code would always throw. These are warnings (the code still compiles):
| Code | Meaning |
|---|---|
KOI0310 | The whole invariant condition is a constant that can never hold (always false). |
KOI0311 | A field’s inclusive bounds are inverted (the lower bound exceeds the upper bound), e.g. x >= 100 && x <= 0. |
KOI0312 | A field’s constant default lies outside the range its invariants require. |
KOI0313 | Two bounds on the same field cannot both hold; their intersection is empty, e.g. amount > 100 && amount < 10. |
A when-guarded invariant is conditional, so it is never flagged. Exhaustiveness of a
smart-enum Match stays a compile-time guarantee of the generated code — it is
deliberately not re-checked here.
10.3.4 Reserved words
Section titled “10.3.4 Reserved words”invariant and matches are fully reserved — they cannot be used as field names
or identifiers anywhere in a .koi file. This is distinct from contextual keywords such
as value or quantity, which may appear as field names in positions where the parser
can unambiguously resolve them.
10.4 Translation to C#
Section titled “10.4 Translation to C#”Every failing invariant throws the same runtime type, emitted once into your output as
Koine.Runtime.DomainInvariantViolationException:
public sealed class DomainInvariantViolationException : Exception{ public string TypeName { get; } public string Rule { get; }
public DomainInvariantViolationException(string type, string rule) : base($"Invariant violated on {type}: {rule}") { … }}The same exception is reused for illegal state transitions (§11)
and unmet command preconditions (§11) (requires), so a single
catch (DomainInvariantViolationException ex) can surface any domain-rule failure, with
ex.TypeName and ex.Rule available for logging or mapping to an API error.
The general emit pattern is always:
if (!(<expr>)) throw new DomainInvariantViolationException( type: nameof(DeclaringType), rule: "the message string");Here is the C# Koine emits for the Price example above:
public Price(decimal amount, Currency currency){ if (!(amount >= 0)) throw new DomainInvariantViolationException( type: nameof(Price), rule: "a price cannot be negative");
Amount = amount; Currency = currency;}10.5 Boolean invariants
Section titled “10.5 Boolean invariants”The general form is invariant <expr> "<message>". The expression must be boolean and
may reference any field of the type, derived fields, and the full Koine expression
language — comparisons, && / ||, string ops, and collection ops like all, count,
sum, and distinctBy.
value Sku { code: String normalized: String = code.trim.upper invariant code.trim.length > 0 "a SKU cannot be blank"}if (!(code.Trim().Length > 0)) throw new DomainInvariantViolationException( type: nameof(Sku), rule: "a SKU cannot be blank");The pattern is always the same: Koine emits if (!(<expr>)) throw …. The message string
becomes the rule argument, and type is the declaring type’s name. On an aggregate
root with collection fields the expressions are richer, but the shape is identical:
invariant lines.all(l => l.quantity >= 1) "every line needs a positive quantity"invariant lines.distinctBy(l => l.product) "no duplicate products in an order"if (!(lines.All(l => (l.Quantity >= 1)))) throw new DomainInvariantViolationException( type: nameof(Order), rule: "every line needs a positive quantity");
if (!(lines.Select(l => l.Product).Distinct().Count() == lines.Count)) throw new DomainInvariantViolationException( type: nameof(Order), rule: "no duplicate products in an order");10.6 Regex invariants (matches)
Section titled “10.6 Regex invariants (matches)”For string shape rules, use invariant <field> matches /<regex>/ "<message>". Koine
emits a Regex.IsMatch guard and pulls in using System.Text.RegularExpressions;.
value Email { raw: String normalized: String = raw.trim.lower invariant raw.trim.length > 0 "an email cannot be blank" invariant raw matches /^[^@]+@[^@]+\.[^@]+$/ "invalid email address"}if (!Regex.IsMatch(raw, @"^[^@]+@[^@]+\.[^@]+$", RegexOptions.None, TimeSpan.FromMilliseconds(1000))) throw new DomainInvariantViolationException( type: nameof(Email), rule: "invalid email address");The pattern between the slashes is copied verbatim into a C# verbatim string (@"…"),
so write the regex exactly as .NET’s Regex expects it.
Bounded evaluation (ReDoS hardening)
Section titled “Bounded evaluation (ReDoS hardening)”A matches pattern is author-supplied and a value object is exactly where untrusted
external input (emails, identifiers, free text) crosses the trust boundary. A
catastrophic-backtracking pattern could otherwise turn the constructor into a
ReDoS
sink, so Koine bounds the emitted match per target:
The same neutral regexMatchTimeoutMs intent (a millisecond budget) reaches every code target, but
each runtime can honor it differently — so a target either honors the bound, plumbs it toward the
one seam that could enforce it, or substitutes the nearest mechanism it has. No target silently
discards it:
| Target | Policy | Emitted form (default → when the key is set) | Notes |
|---|---|---|---|
| C# | honors | Regex.IsMatch(raw, @"…", RegexOptions.None, TimeSpan.FromMilliseconds(1000)) | A real per-call match timeout (default 1000 ms). A timed-out match throws a contained RegexMatchTimeoutException — it never hangs. |
| Python | honors | re.search(r"…", raw) is not None → regex.search(r"…", raw, timeout=<ms/1000>) is not None | When set, lowers to the third-party regex module’s timeout= (seconds) — the one Python path with a real per-call bound — and adds an import regex. Unset keeps stdlib re, so users who don’t opt in take on no new dependency. |
| TypeScript | plumbs | regexMatch(/…/, raw) → regexMatch(/…/, raw, <ms>) | JS RegExp has no synchronous per-call timeout, so the bound is advisory: the value is threaded into the regexMatch seam’s timeoutMs? parameter — the single place to swap in a linear-time engine (e.g. RE2) that can enforce it. Default-engine matching is unchanged. |
| PHP | substitutes | (bool)preg_match('/…/', $raw) → same, annotated with the budget | PHP has no per-call wall-clock timeout; PCRE is already bounded by pcre.backtrack_limit / pcre.recursion_limit (set by default), so a catastrophic pattern fails the match instead of hanging. When the key is set the emitted preg_match is annotated with that PCRE-limit substitute and the author’s budget rather than dropping it. |
| Rust | n/a | koine_runtime::regex_is_match(r"…", &raw) | The regex crate is a linear-time automaton with no catastrophic backtracking — no timeout is needed by construction. |
The bound is configurable via the koine.config key
targets.<target>.regexMatchTimeoutMs (default 1000 for C#): set a
tighter bound for hostile-input value objects, or a looser one for a legitimately expensive pattern on
trusted batch input. For a one-off override without editing the config, pass
--regex-match-timeout-ms <ms> on the command line — the flag wins over
the config key for that invocation. The value must be a positive integer number of milliseconds — 0
or any negative value is rejected at build time (regexMatchTimeoutMs must be a positive integer (milliseconds); got '…'), because for C# it would otherwise flow into the generated
TimeSpan.FromMilliseconds(N) and throw at the generated code’s own runtime. Disabling the bound is
intentionally not supported — the whole point of the guard is to have one. A non-integer value is
likewise a hard error (the same diagnostic is raised) — unlike most config keys that are
forward-compatibly skipped, a non-parseable timeout value would silently disarm the ReDoS guard.
The generated TypeScript regexMatch helper is the seam to harden untrusted-input matching without
touching every call site — replace its body with a linear-time engine (e.g. RE2) and every matches
invariant inherits the bound; when regexMatchTimeoutMs is set, the author’s budget is already threaded
in as the helper’s advisory timeoutMs? argument for that engine to honor.
Source-generated form (opt-in, C#)
Section titled “Source-generated form (opt-in, C#)”A hot-path value object — an Email, an identifier, a free-text field constructed thousands of
times — pays a per-call cost in the inline form: the static Regex.IsMatch(string, string, …) overload
parses the pattern and builds its automaton on every call, even though the pattern is a compile-time
constant. The opt-in RegexMode.SourceGenerated mode emits the .NET
[GeneratedRegex]
source-generator form instead: the pattern is compiled once, ahead of time, into a cached,
allocation-free matcher.
With the mode on, the same Email emits:
public sealed partial class Email : ValueObject // the type becomes `partial`{ public string Raw { get; }
public Email(string raw) { if (!RawRegex0().IsMatch(raw)) // the call site uses the cached matcher throw new DomainInvariantViolationException( type: nameof(Email), rule: "invalid email address"); Raw = raw; }
[GeneratedRegex(@"^[^@]+@[^@]+\.[^@]+$", RegexOptions.None, matchTimeoutMilliseconds: 1000)] private static partial Regex RawRegex0(); // compiled once by the source generator}This is a performance optimization, not a behavior change. The pattern, RegexOptions.None, and the
match timeout are identical to the inline form — only the evaluation strategy changes. The same
targets.csharp.regexMatchTimeoutMs bound flows into
matchTimeoutMilliseconds: exactly as it flows into TimeSpan.FromMilliseconds(N) above, so a timed-out
match still surfaces the same contained RegexMatchTimeoutException. A type holding several matches
invariants gets one deterministically-named partial method per pattern (RawRegex0, RawRegex1, …), so
the output is stable across rebuilds.
The mode is default-off: unless it is enabled, every matches invariant emits the inline form
above, byte-for-byte. It applies only to value objects and entities (which declare the partial method
and become partial); a matches in a spec, domain service, or generated validator keeps the inline
bounded form, so output always compiles. It requires C# 11+ / .NET 7+ (the [GeneratedRegex] source
generator), which a default net8.0+ target satisfies. The other emitter targets are unaffected — their
bounded forms in the table above are unchanged.
Selecting the mode via koine.config. Add targets.csharp.regexMode = sourceGenerated to opt in
from the command line or CI (see targets.csharp.regexMode):
# koine.configtarget = csharpout = ./generated
# opt in to the cached [GeneratedRegex] form for `matches` invariants (default: inline)targets.csharp.regexMode = sourceGeneratedThe default is inline, so an unconfigured koine build produces byte-identical output. Any value other
than inline or sourceGenerated is rejected at build time with a friendly diagnostic.
10.7 Conditional invariants (when)
Section titled “10.7 Conditional invariants (when)”Sometimes a rule only applies in a particular state. Append a when <cond> clause and the
invariant body is only enforced when the condition holds:
invariant status == Draft when lines.isEmptyThis reads as “when the order has no lines, its status must be Draft”. Koine compiles
the when condition into a short-circuiting && in front of the negated body — the guard
only fires when the condition is true and the rule is broken:
if (lines.Count == 0 && !(status == OrderStatus.Draft)) throw new DomainInvariantViolationException( type: nameof(Order), rule: "status == Draft when lines.isEmpty");10.8 Spec-backed invariants
Section titled “10.8 Spec-backed invariants”A specification (§13) is a named, reusable boolean predicate
declared with spec <Name> on <Type> = <expr>. You can reference it as an invariant by its
bare name — no message required — and Koine inlines the predicate into the constructor guard:
spec HasLines on Order = !lines.isEmpty
entity Order identified by OrderId { lines: List<OrderLine> invariant HasLines "an order must have at least one line"}The spec’s body is inlined at the guard site, so the emitted check is the same as if you
had written the expression directly — but the rule now lives in one named place and can be
reused in commands, derived fields, and other specs. The spec must target the same type the
invariant lives on (otherwise you get a SpecTargetMismatch error), and if the inlined body
uses collection ops, using System.Linq; is pulled into the file.
See Specs, services & policies (§13) for the full story on declaring, composing, and reusing named predicates.
10.9 Quick reference
Section titled “10.9 Quick reference”| Form | Emits |
|---|---|
invariant <expr> "msg" | if (!(<expr>)) throw … |
invariant <field> matches /re/ "msg" | if (!Regex.IsMatch(<field>, @"re", RegexOptions.None, TimeSpan.FromMilliseconds(1000))) throw … |
invariant <body> when <cond> | if (<cond> && !(<body>)) throw … |
invariant <SpecName> "msg"? | inlines the named spec’s predicate into the guard |
See also
Section titled “See also”- Value objects (§5) — where most invariants live; see §5.3.1 for inline validating-constructor examples.
- Expressions (§9) — the full expression grammar used in invariant bodies.
- Specs, services & policies (§13) — reusable named predicates you can use as invariants.
- Commands, events & state machines (§11) —
requirespreconditions (the command-level cousin of invariants) and legal transitions, also guarded byDomainInvariantViolationException.