Skip to content

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.

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#:

Terminal window
koine build billing.koi --target csharp --out ./generated

and 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.

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 single runtime.ts — and because JavaScript’s number is lossy for money, that runtime ships a string-backed Decimal class, so Money.amount is a Decimal, not a number.
  • The exception. C# throws DomainInvariantViolationException; idiomatic TypeScript throws a DomainInvariantViolationError (the …Error suffix is the JS convention).
  • The guard. The C# emitter simplifies amount >= 0 to the inverted guard if (amount < 0); the TypeScript emitter keeps the explicit if (!(amount.compareTo(new Decimal('0')) >= 0)), routed through Decimal.compareTo. Same rule, two natural spellings.
  • The smart enum. In C#, Currency becomes a sealed class : IEquatable<Currency> with static instances. In TypeScript it becomes a CurrencyMember interface plus a const Currency = { … } object and free functions (CurrencyFromName, CurrencyMatch, …) — the shape a TS developer expects. That’s why Money.currency is typed Currency in C# but CurrencyMember in TS.
  • Even the folders. C# uses Billing/ValueObjects/… and Billing/Enums/…; the TypeScript emitter uses lower-case, kebab-case Billing/value-objects/… and Billing/enums/…, plus a tsconfig.json.

None of these choices live in the model. They live in their respective emitters.

.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 files

Everything 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.