One model, many targets: how Koine stays language-agnostic
A domain model isn’t a C# thing. “A Money is an amount and a currency, and the amount can’t be
negative” is true regardless of the language you ship it in. Koine takes that seriously: the parser
and the semantic model are kept strictly target-agnostic, and no C# concept is allowed to leak
into them. The payoff is that adding a language is writing a new emitter, not rewriting the compiler
— and you can prove it by flipping one flag.
The same model, a different flag
Section titled “The same model, a different flag”Here’s a Billing context with a smart enum and a value object:
context Billing {
enum Currency(symbol: String, decimals: Int) { EUR("€", 2) USD("$", 2) GBP("£", 2) }
value Money { amount: Decimal currency: Currency invariant amount >= 0 "a monetary amount cannot be negative" }}Build it for C#:
koine build billing.koi --target csharp --out ./generatedand Money lands at Billing/ValueObjects/Money.cs:
public sealed class Money : ValueObject{ public decimal Amount { get; } public Currency Currency { get; }
public Money(decimal amount, Currency currency) { if (amount < 0) { throw new DomainInvariantViolationException( type: nameof(Money), rule: "a monetary amount cannot be negative"); }
Amount = amount; Currency = currency; }
public override string ToString() => $"Money {{ Amount = {Amount}, Currency = {Currency} }}";
protected override IEnumerable<object?> GetEqualityComponents() { yield return Amount; yield return Currency; }}Now change one word — --target typescript — and the same model compiles to
Billing/value-objects/Money.ts:
// <auto-generated/>import { DomainInvariantViolationError, Decimal, ValueObject } from '../../runtime';import { CurrencyMember } from '../enums/Currency';
export class Money extends ValueObject { readonly amount: Decimal; readonly currency: CurrencyMember;
constructor(amount: Decimal, currency: CurrencyMember) { super(); if (!(amount.compareTo(new Decimal('0')) >= 0)) { throw new DomainInvariantViolationError('Money', 'a monetary amount cannot be negative'); } this.amount = amount; this.currency = currency; }
protected equalityComponents(): readonly unknown[] { return [this.amount, this.currency]; }}Same fields, same invariant, same value-equality semantics — expressed idiomatically for each language. The model didn’t change; only the emitter did.
Idiomatic means different, on purpose
Section titled “Idiomatic means different, on purpose”A naïve “transpiler” would emit C#-shaped TypeScript. Koine’s emitters instead lean into each language’s conventions, which is why the two outputs aren’t line-for-line translations:
- The runtime. C# gets a
Koine/Runtime/folder of marker types (ValueObject,DomainInvariantViolationException, …) emitted only when used. TypeScript gets a singleruntime.ts— and because JavaScript’snumberis lossy for money, that runtime ships a string-backedDecimalclass, soMoney.amountis aDecimal, not anumber. - The exception. C# throws
DomainInvariantViolationException; idiomatic TypeScript throws aDomainInvariantViolationError(the…Errorsuffix is the JS convention). - The guard. The C# emitter simplifies
amount >= 0to the inverted guardif (amount < 0); the TypeScript emitter keeps the explicitif (!(amount.compareTo(new Decimal('0')) >= 0)), routed throughDecimal.compareTo. Same rule, two natural spellings. - The smart enum. In C#,
Currencybecomes asealed class : IEquatable<Currency>with static instances. In TypeScript it becomes aCurrencyMemberinterface plus aconst Currency = { … }object and free functions (CurrencyFromName,CurrencyMatch, …) — the shape a TS developer expects. That’s whyMoney.currencyis typedCurrencyin C# butCurrencyMemberin TS. - Even the folders. C# uses
Billing/ValueObjects/…andBilling/Enums/…; the TypeScript emitter uses lower-case, kebab-caseBilling/value-objects/…andBilling/enums/…, plus atsconfig.json.
None of these choices live in the model. They live in their respective emitters.
Why the boundary is worth defending
Section titled “Why the boundary is worth defending”.koi source → ANTLR lexer/parser → semantic model ← target-agnostic; knows nothing about C# or TS → semantic validator → IEmitter ← C#, TypeScript, Python, PHP, glossary → idiomatic source filesEverything above the emitter line is shared. Parsing, name resolution, type checking, and every
diagnostic happen once, against a model that has no opinion about output languages. An emitter is just
an IEmitter that walks that model and writes text. So a new target inherits all the validation and
correctness work for free — it only has to answer “what does this construct look like in my
language?”
That discipline is also why Koine Studio can show C#, TypeScript, and a
glossary side by side from one model: it’s the same compile, three emitters. C# is the most complete
target today; TypeScript is emitting now, Python (Phase 1: tactical core — value objects,
smart enums, identity entities, events, and repository protocols, all dependency-free and
mypy --strict-clean) and PHP 8.1 (the tactical core and the strategic/CQRS layer — read models,
query handler seams, application services/use cases/operations, specifications, policies, context-map
ACL translators and integration-event subscriber seams — dependency-free, using typed
properties and readonly promoted properties) are available today via the CLI and MCP server. A Rust
target ships too (Phase 1: an idiomatic crate — smart constructors returning Result<_, DomainError>,
exhaustively-matched enums, repository traits, proved by a cargo check meta-test). Your existing
.koi models compile to every target without a single edit — because they never described C# in the
first place.
Want to see it live? Open Koine Studio and flip the target dropdown between C# and TypeScript — same model, recompiled on the spot.