Your first model
In five minutes you’ll write a real .koi model, compile it to C#, and read what comes out.
No DDD theory yet — just the shortest path from a domain idea to compiling code.
If you haven’t installed the compiler, do that first: Installation.
Prefer to try it now? Open Koine Studio
Write hello.koi
Section titled “Write hello.koi”Create a file called hello.koi anywhere you like. We’ll model a tiny slice of a hiring
domain: a validated email address and the candidate who owns it.
context Hiring {
value Email { raw: String invariant raw.trim.length > 0 "an email cannot be blank" invariant raw matches /^[^@]+@[^@]+$/ "invalid email address" }
entity Candidate identified by CandidateId { name: String email: Email }}Three constructs, three jobs:
context Hiring— the bounded context. Every type lives inside one, and the context name becomes the C# namespace.value Email— a value object: immutable, compared by its contents, and guarded byinvariants that run in the constructor.raw.trim.lengthandmatches /…/are part of Koine’s small expression sublanguage.entity Candidate— an entity: it has identity. Theidentified by CandidateIdclause names its ID type; Koine generates that ID value object for you (aGuidwrapper, by default).
Compile it
Section titled “Compile it”Point the compiler at the file and tell it where to write the C#:
koine build hello.koi --target csharp --out ./generatedYou’ll see:
wrote 6 files to ./generatedWhat got generated
Section titled “What got generated”Koine emits one file per type, organized by namespace, plus a tiny shared runtime:
generated/├── Hiring/│ ├── Email.cs # the value object│ ├── Candidate.cs # the entity│ └── CandidateId.cs # the generated ID value object└── Koine/ └── Runtime/ ├── ValueObject.cs ├── DomainInvariantViolationException.cs └── IAggregateRoot.csThe value object — Email.cs
Section titled “The value object — Email.cs”The two invariants became constructor guards. Construct an Email with a blank or malformed
string and it throws; once built, it can never be invalid. Equality is by value (two Emails
with the same Raw are equal).
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; }
protected override IEnumerable<object?> GetEqualityComponents() { yield return Raw; }}The entity — Candidate.cs
Section titled “The entity — Candidate.cs”Notice the difference from the value object: a Candidate is compared by its Id alone, not
its fields. Two candidates with the same name and email are still different people — that’s
what “has identity” means.
public sealed class Candidate{ public CandidateId Id { get; } public string Name { get; } public Email Email { get; }
public Candidate(CandidateId id, string name, Email email) { Id = id; Name = name; Email = email; }
public bool Equals(Candidate? other) => other is not null && Id.Equals(other.Id); public override bool Equals(object? obj) => Equals(obj as Candidate); public override int GetHashCode() => Id.GetHashCode();}The generated ID — CandidateId.cs
Section titled “The generated ID — CandidateId.cs”You never declared CandidateId as a type — identified by CandidateId was enough. By
default it’s a Guid wrapper with a New() factory for minting fresh identities:
public sealed class CandidateId : ValueObject{ public Guid Value { get; }
public CandidateId(Guid value) => Value = value;
public static CandidateId New() => new(Guid.NewGuid());
protected override IEnumerable<object?> GetEqualityComponents() { yield return Value; }}Now make it richer
Section titled “Now make it richer”Two small additions show off two of Koine’s most-used features. Replace your hello.koi with
this:
context Hiring {
enum Stage { Applied, Interviewing, Offered, Hired, Rejected }
value Email { raw: String invariant raw.trim.length > 0 "an email cannot be blank" invariant raw matches /^[^@]+@[^@]+$/ "invalid email address" }
entity Candidate identified by CandidateId { name: String email: Email stage: Stage = Applied
isActive: Bool = stage != Hired && stage != Rejected }}What changed:
enum Stage { … }— a smart enum. Koine emits a sealed class with static instances, value equality,Name/Value,All, andFromName/FromValue— far more than a C#enum.stage: Stage = Applied— a field with a default. New candidates start atAppliedwithout the caller passing it.isActive: Bool = stage != Hired && stage != Rejected— a derived field. Because its value is computed from other fields, it’s not a constructor parameter; it becomes a get-only computed property.
Rebuild:
koine build hello.koi --target csharp --out ./generatedThe entity now carries the default and the derived projection:
public sealed class Candidate{ public CandidateId Id { get; } public string Name { get; } public Email Email { get; } public Stage Stage { get; }
public Candidate(CandidateId id, string name, Email email, Stage? stage = null) { stage ??= Stage.Applied;
Id = id; Name = name; Email = email; Stage = stage; }
public bool IsActive => (Stage != Stage.Hired) && (Stage != Stage.Rejected);
public bool Equals(Candidate? other) => other is not null && Id.Equals(other.Id); public override bool Equals(object? obj) => Equals(obj as Candidate); public override int GetHashCode() => Id.GetHashCode();}Where to go next
Section titled “Where to go next”- Reading the generated C# — a guided tour of the output: namespaces, the runtime, equality, and how each construct maps to code.
- Values & invariants — the first tutorial, building a real domain one construct at a time.
- Language reference — every construct, formally, with the exact C# it emits.