Skip to content

Entities & identity

An entity is a domain object with a continuous identity. Two customers with the same name, email, and address are still two different customers — what makes them the same is their identity, not their data. Koine bakes this distinction into the language: you declare an entity with a typed identity, and the compiler gives you identity-only equality and a strongly-typed ID value object for free.

Entity bodies support the full member vocabulary — required and optional (T?) fields, Set<T> and List<T> collections, enum-typed fields, and derived fields (displayName: String = nickname ?? name). See Contexts & types (§4) for the complete field and member vocabulary.

An entity is declared with the entity keyword, an identified by clause naming the identity type, and an optional as clause selecting the identity strategy (see §6.5):

entity_decl
: 'entity' Identifier 'identified' 'by' Identifier identity_strategy?
'{' member* invariant* states_decl* command_decl* factory_decl* '}'
;
identity_strategy
: 'as' ( 'guid'
| 'sequence'
| 'natural' '(' type_name ')' )
;
member
: Identifier ':' type_ref ( '=' expression )?
;
invariant
: 'invariant' expression StringLiteral?
;

The first Identifier is the entity name; the second (after identified by) is the identity type name. By convention it is the entity name plus Id (CustomerCustomerId, OrderOrderId), but any name works. The identity type is generated automatically — you never declare it yourself. The identity_strategy clause is optional; omitting it gives the Guid default.

The member, invariant, states_decl, command_decl, and factory_decl productions inside the body are shared with other constructs. The expression grammar is specified in Expressions (§9); invariant guards are detailed in Invariants (§10).

entity Customer identified by CustomerId {
name: String
email: Email
shippingAddress: PostalAddress
}

This is the same pattern you will see throughout the showcase domain: every aggregate root and every standalone entity is entity X identified by XId { … }.

The entity’s Equals and GetHashCode compare only the Id field — every other field is ignored. This means you can load an entity, mutate it, and still recognise it as “the same” object. A HashSet<Customer> keys on identity. Re-fetching an aggregate from a repository and comparing it to the one you held compares identity, not a snapshot of mutable state. You get the correct DDD semantics without hand-writing Equals/GetHashCode (and without the classic bug of forgetting to keep them in sync).

The generated Id value object provides the underlying equality. Its own equality is structural — it compares the wrapped primitive — so new CustomerId(g) == new CustomerId(g) is true.

Once an entity declares identified by XId, that XId type becomes a first-class reference you can use anywhere a type is expected — most often to point one entity at another:

entity Order identified by OrderId {
customer: CustomerId
}

Here customer: CustomerId references a Customer by its identity, not by embedding the whole customer. This keeps aggregates small and the boundaries between them explicit — an Order holds a CustomerId, not a Customer. The same CustomerId type flows through factory parameters, command arguments, events, and repository finders.

Each generated ID value object lands in its own file, named after the ID type, not the entity: CustomerIdCustomers/CustomerId.cs, ProductCodeCatalog/ProductCode.cs. The ID type name must therefore be unique within its context.

The entity becomes a plain sealed class with one twist: equality is computed only from the identity. Here is the Customer entity from §6.2, as the compiler emits it:

public sealed class Customer
{
public CustomerId Id { get; }
public string Name { get; }
public Email Email { get; }
public PostalAddress ShippingAddress { get; }
public Customer(CustomerId id, string name, Email email, PostalAddress shippingAddress)
{
Id = id;
Name = name;
Email = email;
ShippingAddress = shippingAddress;
}
public bool Equals(Customer? other) => other is not null && Id.Equals(other.Id);
public override bool Equals(object? obj) => Equals(obj as Customer);
public override int GetHashCode() => Id.GetHashCode();
}

Two Customer instances are equal exactly when their Id matches — every other field is ignored.

The generated Id property is of the emitted ID value object type. The ID value object itself derives from ValueObject (in Koine.Runtime) and contributes the wrapped primitive to structural equality via GetEqualityComponents(). The exact shape of the ID value object depends on the identity strategy in use; see §6.5 for per-strategy emitted shapes.

By default an identity is a Guid that the client generates. But not every key is a Guid: SKUs are strings from a supplier catalogue, invoice numbers are store-assigned sequences. The as clause after identified by XId selects one of four strategies. Omit it entirely to get the Guid default.

StrategyDeclarationBacking typeNew()?Validation
Guid (default)identified by XIdGuidyes — XId.New()none
Natural stringidentified by XId as natural(String)stringnorejects blank/whitespace
Natural intidentified by XId as natural(Int)intnonone
Sequenceidentified by XId as sequencelongnonone (store-assigned)

The key distinction is who creates the key. Only the Guid default emits a client-side New() generator. Natural keys come from the real world, and sequence keys are assigned by the persistence store — so neither gets a New().

entity Customer identified by CustomerId {
name: String
}

Emits a Guid-backed value object with a client-side generator:

public sealed class CustomerId : ValueObject
{
public Guid Value { get; }
public CustomerId(Guid value) => Value = value;
public static CustomerId New() => new(Guid.NewGuid());
protected override IEnumerable<object?> GetEqualityComponents()
{
yield return Value;
}
}

This is the only strategy with a New() factory — call CustomerId.New() whenever you mint a fresh entity. (Aggregate factories (§12) call it for you: a create block synthesises var id = CustomerId.New(); automatically.)

For keys that come from outside your system — a SKU, a country code, an ISBN. From the ProductCatalog aggregate in the demo:

entity Product identified by ProductCode as natural(String) {
sku: Sku
name: String
price: Price
}

Emits a string-backed key with no New(), and a constructor that rejects a blank or whitespace-only key:

public sealed class ProductCode : ValueObject
{
public string Value { get; }
public ProductCode(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new DomainInvariantViolationException(
type: nameof(ProductCode),
rule: "identity value cannot be blank");
Value = value;
}
protected override IEnumerable<object?> GetEqualityComponents()
{
yield return Value;
}
}

Because the key is supplied from the real world, you construct it directly — new ProductCode("ABC-123") — and the constructor guards against an empty string.

The same idea backed by an int — a legacy numeric account number, a CSV row id:

entity LegacyAccount identified by AccountNo as natural(Int) {
balance: Decimal
}

Emits public int Value { get; }, value-equality, and no New().

For store-assigned monotonic keys — an invoice number, an audit-log row id. The persistence layer hands you the value, so there is no generator and no argument:

entity Invoice identified by InvoiceNo as sequence {
amount: Int
}

Emits a long-backed key (note: long, not int), value-equality, and no New():

public sealed class InvoiceNo : ValueObject
{
public long Value { get; }
public InvoiceNo(long value) => Value = value;
protected override IEnumerable<object?> GetEqualityComponents()
{
yield return Value;
}
}

6.6 Entities, aggregates, and repositories

Section titled “6.6 Entities, aggregates, and repositories”

An entity can stand alone or sit inside an aggregate (§7). The two biggest consequences of being an aggregate root are:

  • A repository (§14) interface keyed on the root’s identity (IOrderRepository keyed on OrderId) — non-root and standalone entities get none.
  • A factory (§12) seam: declaring a create block makes the all-args constructor private, forcing construction through the factory.
aggregate ProductCatalog root Product {
entity Product identified by ProductCode as natural(String) {
sku: Sku
name: String
price: Price
}
}
context Sales {
value OrderLine {
product: ProductId
quantity: Int
}
// Standalone entity with the default Guid identity.
entity Customer identified by CustomerId {
name: String
email: String
}
// Aggregate root with a natural string key — no client-side New().
aggregate Order root Order {
entity Order identified by OrderId {
customer: CustomerId
lines: List<OrderLine>
}
}
// Store-assigned sequence key, backed by long.
entity Invoice identified by InvoiceNo as sequence {
order: OrderId
amount: Decimal
}
}