1 · Values & invariants
This is part 1 of a hands-on tutorial that builds up to the Shop demo. We start where every domain starts: the small, immutable, self-validating types that everything else is built from — value objects.
By the end you will be able to declare fields with defaults, compute derived fields from other fields, and guard correctness with the three kinds of invariant Koine supports.
A first value object
Section titled “A first value object”A value object is a type defined entirely by its data, with no identity of its own. In Koine you declare one with value:
value Price { amount: Decimal currency: Currency invariant amount >= 0 "a price cannot be negative"}Each name: Type line is a field. Koine maps its small set of primitives straight onto idiomatic C#:
| Koine | C# |
|---|---|
String | string |
Int | int |
Decimal | decimal |
Bool | bool |
Instant | DateTimeOffset |
Fields become constructor parameters and get-only properties (PascalCased: amount becomes Amount). The emitted class derives from a ValueObject base that gives it structural equality — two Price values with the same amount and currency are equal. See value objects for the full reference.
Defaults
Section titled “Defaults”Give a field a constant default with = value. The field becomes a constructor parameter with that default, so callers can omit it:
entity Customer identified by CustomerId { name: String tier: LoyaltyTier = Bronze}tier: LoyaltyTier = Bronze means new customers start at the Bronze loyalty tier unless told otherwise. (Enum defaults are coalesced to the smart-enum instance, since the value isn’t a compile-time constant in C#.)
Derived fields
Section titled “Derived fields”A field with = expression (rather than = constant) is derived: instead of being stored and passed in, it is computed from the other fields. Derived fields become get-only expression-bodied properties — they cost nothing to store and can never drift out of sync.
Derived fields are where Koine’s small, pure expression sublanguage shines. Here is the Sku value object from the Catalog context, which normalizes its raw code:
value Sku { code: String normalized: String = code.trim.upper invariant code.trim.length > 0 "a SKU cannot be blank" invariant code matches /^[A-Z]{3}-[0-9]{4}$/ "SKU must look like ABC-1234"}normalized: String = code.trim.upper emits a Normalized => Code.Trim().ToUpperInvariant() property. A few of the building blocks you can use in derived expressions:
| What | Koine | Example |
|---|---|---|
| String ops | .trim .upper .lower .length | raw.trim.lower |
| Concatenation | + | street + ", " + city |
| Coalescing | ?? | description ?? name |
| Presence | .isPresent (on optionals) | phone.isPresent |
| Comparison | == != < <= > >= | availability == InStock |
| Conditional | if … then … else … | if tier == Gold then true else false |
These compose. The Customer entity from the Customers context mixes coalescing, a presence check, and a conditional all at once:
nickname: String?phone: String?displayName: String = nickname ?? namehasPhone: Bool = phone.isPresentfreeShipping: Bool = if tier == Gold then true else falsefreeShipping emits the tidy FreeShipping => Tier == LoyaltyTier.Gold;. A String? is an optional field; .isPresent and ?? are how you safely read one. Optionals, defaults and derived members get their own treatment in contexts & types.
The three invariants
Section titled “The three invariants”An invariant is a rule the value must always satisfy. Koine enforces it in the constructor: build an invalid value and it throws DomainInvariantViolationException before you ever hold a bad object. There are three forms.
1. Boolean guard
Section titled “1. Boolean guard”The plain form is any boolean expression plus a message:
invariant amount >= 0 "a price cannot be negative"This emits a constructor guard: if (!(amount >= 0)) throw ….
2. Regex matches /…/
Section titled “2. Regex matches /…/”Use matches /regex/ to validate string shape. The pattern goes between literal slashes:
invariant code matches /^[A-Z]{3}-[0-9]{4}$/ "SKU must look like ABC-1234"This emits a timeout-bounded Regex.IsMatch(code, @"^[A-Z]{3}-[0-9]{4}$", RegexOptions.None, TimeSpan.FromMilliseconds(1000)) guard — the match timeout keeps an author-supplied pattern from becoming a ReDoS sink. Note you can combine a matches rule with an ordinary boolean rule in the same type, as Sku does above (one for non-blankness, one for shape).
3. Conditional when
Section titled “3. Conditional when”A when clause makes the rule apply only under a condition — invariant <body> when <cond> emits if (cond && !body) throw …:
invariant status == Draft when lines.isEmptyRead it as: when there are no lines, the status must be Draft. The guard is skipped entirely when the condition is false.
The emitted C#
Section titled “The emitted C#”Put it together. Here is Email from the Customers context — two invariants (one boolean, one regex) and a derived field:
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"}The compiler emits a self-contained, dependency-free C# value object:
public sealed class Email : ValueObject{ public string Raw { get; }
public Email(string raw) { if (!(raw.Trim().Length > 0)) throw new DomainInvariantViolationException( type: nameof(Email), rule: "an email cannot be blank");
if (!Regex.IsMatch(raw, @"^[^@]+@[^@]+\.[^@]+$", RegexOptions.None, TimeSpan.FromMilliseconds(1000))) throw new DomainInvariantViolationException( type: nameof(Email), rule: "invalid email address");
Raw = raw; }
public string Normalized => Raw.Trim().ToLowerInvariant();
protected override IEnumerable<object?> GetEqualityComponents() { yield return Raw; }}Notice what you got for free: structural equality via GetEqualityComponents, the derived Normalized property, and both invariants enforced before construction completes. You write the rules once; the compiler writes the boilerplate.
A complete, compiling context
Section titled “A complete, compiling context”Everything above lives inside a context — a bounded context that becomes one C# namespace. Here is a minimal but complete model you can paste into a .koi file and compile with koine build:
context Catalog { enum Currency(symbol: String, decimals: Int) { EUR("€", 2) USD("$", 2) GBP("£", 2) }
value Sku { code: String normalized: String = code.trim.upper invariant code.trim.length > 0 "a SKU cannot be blank" invariant code matches /^[A-Z]{3}-[0-9]{4}$/ "SKU must look like ABC-1234" }
value Price { amount: Decimal currency: Currency invariant amount >= 0 "a price cannot be negative" }}Run it:
koine build catalog.koi --target csharp --out gen/You now have validated, self-contained value objects. In part 2 we give a type identity and turn it into an entity, then assemble entities into an aggregate.
Continue to 2 · Entities & aggregates, or dig deeper in the value objects reference.