Skip to content

Application layer & CQRS

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:

ConstructKoine keywordRole
Unit of work(emergent — no keyword)Transactional boundary over all aggregates in a context
Application serviceservice / usecaseCommand side: async entry points for controllers, handlers, or endpoints
Read modelreadmodelFlat, denormalized projection of an aggregate with a static mapper
Query objectqueryRequest DTO over a read model with a generic handler contract

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)
}

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 Identifier with 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 '=' expression form. 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
}

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>

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 (OrderOrders).
  • Properties appear in the same order the aggregates are declared.
  • A context with no aggregates emits no IUnitOfWork.cs at all.
.koiEmitted C#
usecase Name(...)one async method on the I<Service> interface
usecase Name(...): Rreturns Task<R>
usecase Name(...) (no return)returns Task
List<T> parametersurfaces as IReadOnlyList<T> in the signature
service name OrderingServiceinterface 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.

  • A direct field (bare name) must actually exist on the source aggregate; a missing name raises a ReadModelUnknownField diagnostic.
  • The from source must be a type already declared in the context; an unknown source raises a ReadModelUnknownSource diagnostic.
  • Duplicate fields are rejected — including case-only collisions, since field names PascalCase into record members (total and Total both become Total).
  • Read models emit a plain record: no IAggregateRoot, no invariants.
  • A query is declared at context level, not inside a service.
  • The result type is required (unlike usecase, where it is optional).
  • The result type must be a read model — readmodel M or List<M> — otherwise the compiler raises a QueryResultNotReadModel diagnostic.
  • The IQueryHandler.cs runtime 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.

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);
}

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);
}

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.

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 body
new CartTotal(src.Lines.Sum(l => l.Quantity)); // file gains: using System.Linq;

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>

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): ProductCard

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.