Skip to content

5 · Many bounded contexts

By part 4 you had a complete Ordering context: an aggregate, a unit of work, application services, read models and queries. Real systems aren’t one context, though — they’re several, each owning its own language, meeting at well-defined seams.

This part splits the shop into separate bounded contexts across separate files, references types across those contexts, and draws the context map that says how they relate. Koine doesn’t just record those relationships — it enforces them and drives emission from them.

Point the compiler at a directory and every .koi under it compiles as a single model:

Terminal window
koine build ./models --out Generated

The CLI recurses for *.koi (in a deterministic order) and merges them before checking anything. That has two consequences:

  • Same context, many files — split a context across files freely; the types merge into one namespace and cross-reference each other with no ceremony.
  • The context map can name contexts from any file — because the whole folder is one model, a map in context-map.koi can wire up contexts declared in ordering.koi, shipping.koi, and the rest.

Our shop has six contexts, one file each, plus the map:

Models/
├── catalog.koi # Catalog : products, prices, weights
├── customers.koi # Customers : customers, addresses, loyalty
├── ordering.koi # Ordering : the Order aggregate
├── shipping.koi # Shipping : the Shipment aggregate
├── payments.koi # Payments : Payment + Ledger aggregates
├── legacy.koi # Legacy : an external gateway we don't control
└── context-map.koi # the strategic map tying them together

A type in one context can’t silently leak into another. If Shipping wants the PostalAddress value object that Customers owns, you must say so — in one of three ways.

Named import — bring specific names in and use them unqualified:

context Shipping version 1 {
import Customers.{ PostalAddress }
// PostalAddress is now usable as-is
}

Wildcard importimport Customers.* brings in everything Customers exports.

Fully-qualified reference — skip the import and write Customers.PostalAddress inline.

The named import emits a precise using Customers; into the importing type’s file; a qualified reference emits the fully-qualified C# type and no using. Pick whichever reads best.

Within a context, a module groups types into a sub-namespace and a sub-folder. Shipping nests its aggregate inside Fulfillment:

context Shipping version 1 {
import Customers.{ PostalAddress }
module Fulfillment {
aggregate Shipment root Shipment {
entity Shipment identified by ShipmentId {
order: OrderId
destination: PostalAddress
weight: Weight
status: ShipmentStatus = Pending
}
}
}
}

Shipment now emits to Shipping/Fulfillment/Shipment.cs with namespace Shipping.Fulfillment;. Identity types and the unit of work stay in the base namespace (Shipping/ShipmentId.cs, Shipping/IUnitOfWork.cs), and the UoW references the module-qualified repository — exactly the placement a hand-written DDD codebase would use.

The strategic view is a top-level contextmap block — a sibling of context, never nested in one. Each line names two contexts, an arrow, and a role:

contextmap {
// Shared kernel: Catalog and Ordering jointly own Currency.
Catalog <-> Ordering : shared-kernel { Currency }
// Conformist: Shipping conforms to Catalog's Weight (direct reference permitted).
Catalog -> Shipping : conformist
// Customer–supplier: Customers (upstream) supplies the PostalAddress Shipping imports.
Customers -> Shipping : customer-supplier
// Open-host: Ordering publishes OrderPlaced; this authorizes the subscriptions below.
Ordering -> Shipping : open-host
Ordering -> Payments : open-host
// Partnership: Shipping and Payments coordinate to fulfil an order together.
Shipping <-> Payments : partnership
// Anti-corruption layer: Payments translates the Legacy gateway's model into its own.
Legacy -> Payments : anti-corruption-layer
acl {
Legacy.GatewayResult -> Payments.PaymentReceipt
}
}

The arrows are atomic tokens: -> is directed (upstream -> downstream), <-> is bidirectional. The seven roles are single hyphenated tokens — shared-kernel, customer-supplier, open-host, anti-corruption-layer, conformist, partnership, published-language — so never put spaces around the hyphens. A given pair of contexts may carry one relation: declaring two (even with different roles or directions) is DuplicateContextRelation.

RoleMeaningWhat Koine does with it
shared-kernelBoth contexts jointly own the listed typesEmits each shared type once into a kernel namespace both share
conformistDownstream takes upstream’s model as-isPermits a direct cross-context reference (no import needed)
customer-supplierUpstream supplies, downstream consumesDocuments the supply relationship; downstream still imports
open-hostUpstream offers a published languageAuthorizes downstream subscribes to its integration events
partnershipTwo contexts evolve togetherRecords a bidirectional coordination relationship
anti-corruption-layerDownstream shields itself behind a translatorEmits a translator interface from the acl { } mappings
published-languageA formally shared interchange modelRecords the published-language relationship

Catalog <-> Ordering : shared-kernel { Currency } means Currency belongs to both contexts, so Koine emits it exactly once into a dedicated kernel namespace rather than duplicating it:

namespace Catalog__Ordering.Kernel;
public sealed class Currency : IEquatable<Currency>
{
public static readonly Currency EUR = new("EUR", 0, "", 2);
public static readonly Currency USD = new("USD", 1, "$", 2);
// ...
}

Both partners get a precise using Catalog__Ordering.Kernel; wherever they touch Currency. The namespace is the two context names joined by __, alphabetically.

Anti-corruption layer — a generated translator

Section titled “Anti-corruption layer — a generated translator”

Legacy is an external gateway we don’t control, so Payments never references its GatewayResult directly. The acl { } block maps the legacy type onto a Payments type, and Koine emits a translator interface in the downstream context:

namespace Payments;
/// <summary>Anti-corruption translator from upstream context Legacy into Payments.</summary>
public interface ILegacyToPaymentsTranslator
{
Payments.PaymentReceipt Translate(Legacy.GatewayResult source);
}

One Translate method per acl mapping, with fully-qualified types. You implement it; the legacy model never crosses the boundary.

The map authorizes the shape of collaboration; integration events are the actual messages that flow across it. Unlike domain events (which stay inside an aggregate), an integration event is a published language — its fields stay primitive, never leaking internal value objects.

Ordering declares the event and publishes it:

context Ordering version 1 {
integration event OrderPlaced {
orderId: OrderId
customer: CustomerId
total: Decimal
placedAt: Instant
}
publishes OrderPlaced
// ...the Order aggregate
}

OrderPlaced emits a sealed record OrderPlaced : IIntegrationEvent carrying an OccurredOn stamp. Subscribers react to it. Both Shipping and Payments subscribe, each authorized by their open-host relation to Ordering:

context Shipping version 1 {
subscribes Ordering.OrderPlaced
// ...
}

That subscribes emits a handler seam in the subscriber — never a copy of the event:

namespace Shipping;
public interface IHandleOrderPlaced
{
Task Handle(Ordering.OrderPlaced theEvent, CancellationToken ct = default);
}

The event type is fully qualified back to its publisher (Ordering.OrderPlaced); the subscriber depends on it but never redefines it. Implement IHandleOrderPlaced in Shipping and Payments to wire each context’s reaction.

If a context subscribes to two same-named events from different publishers — say both Sales.Shipped and Returns.Shipped — the bare IHandleShipped seam would collide, so each is qualified by its publisher instead: IHandleSalesShipped and IHandleReturnsShipped, each typed on its own publisher’s event. The single-publisher case keeps the plain IHandle<Event> name.

Building the whole Models/ folder in one pass produces the cross-context artifacts that prove the map was honoured:

Generated/
├── Catalog__Ordering/Kernel/Currency.cs # shared kernel — emitted once
├── Ordering/OrderPlaced.cs # the integration event record
├── Shipping/IHandleOrderPlaced.cs # subscription seam
├── Payments/IHandleOrderPlaced.cs # subscription seam
├── Payments/ILegacyToPaymentsTranslator.cs # ACL translator interface
├── Shipping/Fulfillment/Shipment.cs # the module sub-namespace
└── Koine/Runtime/IIntegrationEvent.cs # emitted once, when ≥1 integration event exists

Here is the whole pattern — directory mode, an import, a module, a context map with three roles, and pub/sub — small enough to read in one sitting. Save the three blocks as separate .koi files in one folder and run koine build <folder>.

context Customers version 1 {
value PostalAddress {
line1: String
city: String
postcode: String
}
}
context Ordering version 1 {
enum Currency { EUR, USD, GBP }
integration event OrderPlaced {
orderId: OrderId
total: Decimal
placedAt: Instant
}
publishes OrderPlaced
aggregate Order root Order {
entity Order identified by OrderId {
total: Decimal
currency: Currency
}
}
}
context Shipping version 1 {
import Customers.{ PostalAddress }
subscribes Ordering.OrderPlaced
enum ShipmentStatus { Pending, Dispatched, Delivered }
module Fulfillment {
aggregate Shipment root Shipment {
entity Shipment identified by ShipmentId {
order: OrderId
destination: PostalAddress
status: ShipmentStatus = Pending
}
}
}
}
contextmap {
Customers -> Shipping : customer-supplier
Ordering -> Shipping : open-host
Customers <-> Ordering : partnership
}

That single build emits Customers, Ordering, and Shipping namespaces, the OrderPlaced record, an IHandleOrderPlaced seam in Shipping, and the Shipping.Fulfillment sub-namespace — every boundary the map declared, materialised in the C#.

  • A directory build compiles many files as one model; same-context files merge, cross-context refs need an import, a qualifier, or a permitting relation.
  • Modules carve sub-namespaces and sub-folders inside a context.
  • The contextmap is top-level and drives emission: a shared kernel emitted once, ACL translators, and the authorisation for pub/sub.
  • Integration events + publishes/subscribes generate IIntegrationEvent records and IHandle* handler seams across the boundary.

For the full grammar and every emitted shape, see Multi-file, imports & modules and Context maps & integration.