Skip to content

Value objects

A value object is a small immutable type defined entirely by its data — two of them are equal when their fields are equal, not when they are the same instance. In Koine you declare one with the value keyword; the compiler emits a sealed class deriving the ValueObject runtime base, with get-only properties, a validating constructor, and structural equality. You never write Equals, GetHashCode, or a copy constructor by hand.

A value object is declared with the value keyword (a quantity§5.5 — shares the same body grammar):

value_declaration
: 'value' Identifier '{' member* invariant* '}'
;
member
: Identifier ':' type_ref ( '=' expression )? // '= expression' makes it a derived field
;
invariant
: 'invariant' expression StringLiteral? // the string is the failure message
;
type_ref
: Identifier ( '<' type_ref ( ',' type_ref )? '>' )? '?'? // T, List<T>, Map<K,V>, T?
;

A member with an = expression initialiser that references sibling fields is a derived field (§5.3); without it, the member is a constructor parameter. The expression grammar is specified in Expressions (§9).

value Price {
amount: Decimal
currency: Currency
invariant amount >= 0 "a price cannot be negative"
}

Every value X { … } produces a class with four guarantees:

AspectEmitted shape
ImmutabilityEvery field becomes a { get; }-only property, set once in the constructor
ValidationEach invariant becomes a guard that throws DomainInvariantViolationException before assignment
Structural equalityEquality (and == / != / GetHashCode) compares the declared fields, in order
SealedThe class is sealed — value objects are not meant to be subclassed

Every invariant you declare becomes a guard at the top of the constructor, in declaration order, each throwing DomainInvariantViolationException(type, rule) with your message as the rule. Invariants can use string operations, comparisons, presence checks, and regex literals:

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"
}

emits:

public Sku(string code)
{
if (!(code.Trim().Length > 0))
throw new DomainInvariantViolationException(
type: nameof(Sku),
rule: "a SKU cannot be blank");
if (!Regex.IsMatch(code, @"^[A-Z]{3}-[0-9]{4}$", RegexOptions.None, TimeSpan.FromMilliseconds(1000)))
throw new DomainInvariantViolationException(
type: nameof(Sku),
rule: "SKU must look like ABC-1234");
Code = code;
}

See Invariants (§10) for the full guard expression grammar and how the same invariant syntax applies to entities and quantities.

A field written name: Type = expr where expr references sibling fields is a derived field. It is not a constructor parameter and not part of equality — it is emitted as a get-only computed property:

value Sku {
code: String
normalized: String = code.trim.upper // derived
}

The normalized field becomes an expression-bodied property, evaluated on each access:

public string Normalized => Code.Trim().ToUpperInvariant();

Derived fields keep your invariants and accessors declarative. In the demo’s OrderLine, pricing is expressed entirely as derived fields over the two real inputs (quantity and unitPrice):

value OrderLine {
product: ProductId
quantity: Int
unitPrice: Money
// Derived: scale Money by a scalar, then apply a conditional discount.
lineTotal: Money = unitPrice * quantity
payable: Money = if quantity >= 10 then lineTotal * 0.9 else lineTotal
}

When a value object field is a List<T> or Set<T>, the constructor takes a defensive copy and exposes it as a read-only view (IReadOnlyList<T> / IReadOnlySet<T>). A caller cannot mutate your value object by holding onto the collection they passed in. Collection fields participate in equality by element — ordered for List<T>, order-insensitive for Set<T> — via the runtime’s Ordered(...) / Unordered(...) helpers on the ValueObject base.

See Contexts & types (§4) for the full list of how Koine types lower to C#.

Per-aspect emitted C# is shown inline in §5.3 above; this section gives the canonical emitted shape.

Here is the C# Koine emits for the Price above:

public sealed class Price : ValueObject
{
public decimal Amount { get; }
public Currency Currency { get; }
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;
}
protected override IEnumerable<object?> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
}

The base ValueObject (in Koine.Runtime) implements Equals, GetHashCode, and the == / != operators once for every value object, comparing the sequence returned by GetEqualityComponents(). Your generated type only contributes the ordered list of components.

A value object with a numeric field can be multiplied or divided by a scalar, and Koine generates the corresponding operator — but only for the operations your model actually uses. The emitter scans derived fields, factories, and commands; each scalar operation it sees on a value object produces exactly one operator overload, carrying the remaining fields unchanged.

In the demo, Money is multiplied by an Int (unitPrice * quantity) and by a Decimal (lineTotal * 0.9), and two Moneys are summed (lines.sum(...)). So Koine emits three operators and nothing more:

value Money {
amount: Decimal
currency: Currency
invariant amount >= 0 "an amount cannot be negative"
}
public static Money operator *(Money left, decimal right) => new Money(left.Amount * right, left.Currency);
public static Money operator *(Money left, int right) => new Money(left.Amount * right, left.Currency);
public static Money operator +(Money left, Money right) => new Money(left.Amount + right.Amount, left.Currency);

Notice the operators preserve Currency and route the result back through the validating constructor, so the arithmetic can never produce an invalid value object.

Division is the dual of multiplication and is demand-generated the same way. A derived field such as half: Money = fee / 2 makes the emitter generate the matching operator /:

public static Money operator /(Money left, int right) => new Money(left.Amount / right, left.Currency);

It divides the numeric field, carries the rest, and routes through the validating constructor — money / 2 scales a value down, exactly as money * 2 scales it up.

When a scaled field is Int and the arithmetic doesn’t land on a whole number — a fractional factor (weight * 1.5) or a non-integer quotient (weight / 2) — the result truncates toward zero: the fractional part is dropped and the sign is kept, e.g. 7 / 2 = 3 and -7 / 2 = -3 (not -4). This is consistent across every emitter target (C#, TypeScript, Python, Rust) — the same .koi model produces the same number regardless of target. Int × Int is always exact, and a Decimal field keeps full precision throughout. PHP is the one outlier: an Int field’s scalar arithmetic routes through its BCMath-backed Decimal runtime rather than a native integer, so it neither rounds nor truncates the way the other four targets do.

Same-type values combine directly, too — not only through a sum fold. A derived field such as total: Money = fee + fee or diff: Money = fee - fee demand-generates the matching same-type operator + / operator -:

public static Money operator -(Money left, Money right) => new Money(left.Amount - right.Amount, left.Currency);

Like +, - subtracts each numeric field, carries the rest, guards that the non-numeric fields agree (EUR - USD throws), and routes through the validating constructor — so a difference that would be negative throws the amount >= 0 invariant at construction, exactly as any other invalid value would.

A value object scales by a scalar — multiply in either operand order (money * 2, 2 * money) or divide it down (money / 2) — and combines with another value of its own type through +/-, whether written directly (fee + fee) or via a sum fold (lines.sum(...)). But a bare scalar is never a valid +/- operand: 5.0 + money or money - 1 is a type mismatch (KOI0215), because there is no value-object ± scalar operation in any target. Division is one-directional too: money / 2 scales the value down, but 2 / money is meaningless — a scalar cannot be divided by a value object — and is rejected the same way (KOI0215). Use * or / to scale.

A quantity is a value object with a Decimal amount and an enum unit that emits unit-checked + / - operators — adding grams to kilograms throws. It also gets scalar * / / by Int and Decimal, preserving the unit. It still validates and compares structurally like any other value object:

quantity Weight {
amount: Decimal
unit: MassUnit
invariant amount >= 0 "a weight cannot be negative"
}
public static Weight operator +(Weight left, Weight right)
{
if (left.Unit != right.Unit)
throw new DomainInvariantViolationException(
type: nameof(Weight),
rule: "cannot add quantities of different units");
return new Weight(left.Amount + right.Amount, left.Unit);
}

The full Catalog context below declares three value objects (Sku, Price, SalePeriod), a quantity (Weight), and the enums they depend on. It is copy-pasteable and compiles with koine build:

context Catalog version 2 {
enum Currency(symbol: String, decimals: Int) {
EUR("€", 2)
USD("$", 2)
GBP("£", 2)
}
enum MassUnit { Gram, Kilogram }
// Validated by shape, normalized with a derived field.
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"
}
// Two real fields, one invariant.
value Price {
amount: Decimal
currency: Currency
invariant amount >= 0 "a price cannot be negative"
}
// A quantity: amount + enum unit, with unit-checked arithmetic.
quantity Weight {
amount: Decimal
unit: MassUnit
invariant amount >= 0 "a weight cannot be negative"
}
// A Range<Instant> interval value object. The built-in Range<T> supplies
// Start/End, a start<=end check, and Contains/Overlaps.
value SalePeriod {
window: Range<Instant>
}
}

The key distinction: value objects have no identity. An entity (§6) is identified by an id and two entities with identical fields are still different things; two value objects with identical fields are the same value. Use a value object for a measurement, a money amount, an address, a code — anything you’d happily replace wholesale rather than mutate.

Identity types themselves (ProductId, OrderId, …) are generated value objects too — small records wrapping a Guid (or a natural key). See Entities & identity (§6) for the strategies.