Application layer & CQRS
15.1 General
Section titled “15.1 General”Koine’s domain model — entities, value objects, aggregates — describes the write side of your system. The application layer wires that model into the outside world: a transactional boundary (IUnitOfWork), use-case entry points (application services), and a read side built from flat projections (readmodel) and query DTOs (query).
Everything on this page is a pure abstraction. The emitted interfaces and records carry no infrastructure dependencies — no Entity Framework, no Dapper, no DbContext. You implement them in your host project however you like; Koine just gives you the shapes.
The four constructs that make up the application layer are:
| Construct | Koine keyword | Role |
|---|---|---|
| Unit of work | (emergent — no keyword) | Transactional boundary over all aggregates in a context |
| Application service | service / usecase | Command side: async entry points for controllers, handlers, or endpoints |
| Read model | readmodel | Flat, denormalized projection of an aggregate with a static mapper |
| Query object | query | Request DTO over a read model with a generic handler contract |
15.2 Syntax
Section titled “15.2 Syntax”15.2.1 Application services
Section titled “15.2.1 Application services”An application service is declared with the service keyword. Each use-case entry point is a usecase inside it:
service_decl : 'service' Identifier '{' service_member* '}' ;
service_member : operation_decl | usecase_decl ;
usecase_decl : 'usecase' Identifier '(' param_list? ')' ( ':' type_ref )? ;
param_list : param ( ',' param )* ;
param : Identifier ':' type_ref ;
type_ref : ( Identifier '.' )? Identifier ( '<' type_ref ( ',' type_ref )? '>' )? '?'? ;usecase_decl names the use case, takes an optional parameter list, and returns an optional result type. A usecase with no : type_ref returns Task (void-async) in C#; one with a result type returns Task<R>. See Specs, services & policies (§13) for the operation_decl variant (pure domain logic that lives on the same service).
service OrderingService { usecase PlaceOrder(customer: CustomerId, lines: List<OrderLine>): OrderId usecase CancelOrder(order: OrderId)}15.2.2 Read models
Section titled “15.2.2 Read models”A read model is declared with readmodel, naming the source aggregate with from. Its body is a list of fields:
readmodel_decl : 'readmodel' Identifier 'from' Identifier '{' readmodel_field* '}' ;
readmodel_field : Identifier ( ':' type_ref '=' expression )? ;A readmodel_field is one of two forms:
- Direct — a bare
Identifierwith no: type_ref = expression. The field is resolved from the source aggregate by name; its type is inherited from the source. - Derived — the full
Identifier ':' type_ref '=' expressionform. Both the type and the expression are required — there is no type-only form.
The expression grammar is specified in Expressions (§9).
readmodel OrderSummary from Order { id customer status lineCount: Int = lines.count}15.2.3 Query objects
Section titled “15.2.3 Query objects”A query object is a context-level declaration with query:
query_decl : 'query' Identifier '(' param_list? ')' ':' type_ref ;Unlike usecase_decl, the result type (: type_ref) is required on query_decl. The result must be a read model name or List<M> where M is a read model name.
query OrdersByStatus(status: OrderStatus): List<OrderSummary>15.3 Semantics
Section titled “15.3 Semantics”15.3.1 Unit-of-work generation
Section titled “15.3.1 Unit-of-work generation”You never write a unit of work in .koi. It is emergent: any context that declares at least one aggregate automatically gets one IUnitOfWork interface, with one repository property per aggregate (in declaration order) plus a SaveChangesAsync.
- Each property is typed
I<Root>Repository— the repository interface Koine generates from the aggregate (see Aggregates & repositories (§7)). - Properties are named with the pluralized root entity name (
Order→Orders). - Properties appear in the same order the aggregates are declared.
- A context with no aggregates emits no
IUnitOfWork.csat all.
15.3.2 Application-service rules
Section titled “15.3.2 Application-service rules”.koi | Emitted C# |
|---|---|
usecase Name(...) | one async method on the I<Service> interface |
usecase Name(...): R | returns Task<R> |
usecase Name(...) (no return) | returns Task |
List<T> parameter | surfaces as IReadOnlyList<T> in the signature |
service name OrderingService | interface IOrderingService |
A service that contains only use cases emits just the I<Service> interface — no domain class. If you mix operation (pure domain logic) and usecase in one service, Koine emits both files: the bare-named class for the operations and the I-prefixed interface for the use cases. See Specs, services & policies (§13) for the operation side.
15.3.3 Read-model rules
Section titled “15.3.3 Read-model rules”- A direct field (bare name) must actually exist on the source aggregate; a missing name raises a
ReadModelUnknownFielddiagnostic. - The
fromsource must be a type already declared in the context; an unknown source raises aReadModelUnknownSourcediagnostic. - Duplicate fields are rejected — including case-only collisions, since field names PascalCase into record members (
totalandTotalboth becomeTotal). - Read models emit a plain record: no
IAggregateRoot, no invariants.
15.3.4 Query-object rules
Section titled “15.3.4 Query-object rules”- A
queryis declared at context level, not inside aservice. - The result type is required (unlike
usecase, where it is optional). - The result type must be a read model —
readmodel MorList<M>— otherwise the compiler raises aQueryResultNotReadModeldiagnostic. - The
IQueryHandler.csruntime file is emitted exactly once for the whole compilation, no matter how many queries you declare; a model with no queries emits no handler file.
15.4 Translation to C#
Section titled “15.4 Translation to C#”15.4.1 Unit of work
Section titled “15.4.1 Unit of work”The Ordering context, which has a single Order aggregate:
context Ordering version 1 { aggregate Order root Order versioned { repository { operations: getById, add, update find byCustomer(customer: CustomerId): List<Order> find mostRecent(customer: CustomerId): Order } entity Order identified by OrderId { customer: CustomerId lines: List<OrderLine> status: OrderStatus = Draft } }}emits Ordering/IUnitOfWork.cs:
namespace Ordering;
/// <summary>Transactional boundary over this context's aggregate repositories.</summary>public interface IUnitOfWork{ IOrderRepository Orders { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);}A context with two aggregates exposes two repositories. Payments declares Payment and Ledger (root entity LedgerEntry):
namespace Payments;
public interface IUnitOfWork{ IPaymentRepository Payments { get; } ILedgerEntryRepository LedgerEntries { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);}15.5 Application services
Section titled “15.5 Application services”15.5.1 Translation to C#
Section titled “15.5.1 Translation to C#”The service / usecase pair emits Ordering/IOrderingService.cs:
namespace Ordering;
public interface IOrderingService{ Task<OrderId> PlaceOrder(CustomerId customer, IReadOnlyList<OrderLine> lines);
Task CancelOrder(OrderId order);}15.6 Read models
Section titled “15.6 Read models”The query side starts with a readmodel: a flat, denormalized projection of an aggregate, plus a static mapper that builds it. This keeps your read DTOs out of the domain model while staying type-safe.
15.6.1 Translation to C#
Section titled “15.6.1 Translation to C#”The readmodel OrderSummary from Order { … } declaration emits Ordering/OrderSummary.cs — a record and a projection extension method:
namespace Ordering;
public sealed record OrderSummary(OrderId Id, CustomerId Customer, OrderStatus Status, int LineCount);
public static class OrderSummaryProjection{ public static OrderSummary ToOrderSummary(this Order src) => new OrderSummary(src.Id, src.Customer, src.Status, src.Lines.Count);}Projection expressions translate like the rest of Koine: .count becomes .Count, and LINQ aggregates pull in using System.Linq; automatically. The Catalog ProductCard uses a comparison expression:
readmodel ProductCard from Product { sku name price available: Bool = availability == InStock}A collection aggregate works the same way and adds the LINQ import:
readmodel CartTotal from Cart { units: Int = lines.sum(l => l.quantity) }// projection mapper bodynew CartTotal(src.Lines.Sum(l => l.Quantity)); // file gains: using System.Linq;15.7 Query objects
Section titled “15.7 Query objects”A query is a request DTO over a read model. Koine emits one record per query (the criteria become its constructor properties) and one shared handler interface for the whole model.
query OrdersByStatus(status: OrderStatus): List<OrderSummary>15.7.1 Translation to C#
Section titled “15.7.1 Translation to C#”Emits Ordering/OrdersByStatus.cs:
namespace Ordering;
public sealed record OrdersByStatus(OrderStatus Status);The result type — List<OrderSummary> vs a bare OrderSummary — does not change the DTO. It only documents the TResult you bind when implementing the handler. The single runtime file Koine/Runtime/IQueryHandler.cs carries that contract:
namespace Koine.Runtime;
public interface IQueryHandler<TQuery, TResult>{ Task<TResult> HandleAsync(TQuery query, CancellationToken ct = default);}You implement one handler per query — for example IQueryHandler<OrdersByStatus, IReadOnlyList<OrderSummary>>. Catalog shows both a list query and a single-result query:
query ProductsByAvailability(availability: Availability): List<ProductCard>query ProductByCode(code: ProductCode): ProductCard15.8 End-to-end example
Section titled “15.8 End-to-end example”For the Ordering context, one .koi file gives you the full vertical slice:
/// Ordering bounded context — placing and pricing customer orders.context Ordering version 1 {
enum OrderStatus { Draft, Submitted, Paid, Shipped, Cancelled } enum Currency { EUR, USD, GBP }
value Money { amount: Decimal currency: Currency invariant amount >= 0 "an amount cannot be negative" }
aggregate Order root Order versioned { repository { operations: getById, add, update find byCustomer(customer: CustomerId): List<Order> find mostRecent(customer: CustomerId): Order }
value OrderLine { product: ProductId quantity: Int unitPrice: Money lineTotal: Money = unitPrice * quantity invariant quantity >= 1 "an order line needs at least one unit" }
entity Order identified by OrderId { customer: CustomerId lines: List<OrderLine> status: OrderStatus = Draft total: Money = lines.sum(l => l.lineTotal) lineCount: Int = lines.count } }
/// The application/use-case service interface. service OrderingService { usecase PlaceOrder(customer: CustomerId, lines: List<OrderLine>): OrderId usecase CancelOrder(order: OrderId) }
/// A flat read model + projection mapper. readmodel OrderSummary from Order { id customer status lineCount: Int = lines.count }
/// A query DTO over the read model. query OrdersByStatus(status: OrderStatus): List<OrderSummary>}From that single context Koine emits, in the Ordering/ folder: the Order aggregate and IOrderRepository, an IUnitOfWork exposing Orders, the IOrderingService application interface, the OrderSummary record and projection, and the OrdersByStatus query DTO — plus the shared Koine/Runtime/IQueryHandler.cs. None of it references your database.
See also
Section titled “See also”- Aggregates & repositories (§7) — where
I<Root>Repositoryand finders come from. - Specs, services & policies (§13) — the
operation,spec, andpolicyconstructs. - Contexts & types (§4) — how
List<T>,Instant, and the rest map to C#. - Expressions (§9) — the expression grammar used in derived read-model fields.
- Commands, events & state machines (§11) — the
commandandeventconstructs that the application layer orchestrates.