Skip to content

Context maps & integration events

A single .koi model usually holds several bounded contexts. The context map is the strategic layer that sits between them: a top-level contextmap { ... } block that names how each context relates to its neighbours. From those typed relationships Koine decides which cross-context references are allowed, where shared types live, which translator interfaces to emit, and which subscriptions are authorized.

This chapter covers the map block itself, the seven relationship roles, the shared-kernel and anti-corruption-layer sub-blocks, the integration event type declaration, and the publishes / subscribes pub/sub seam.

A contextmap is a top-level declaration — a sibling of context, never nested inside one. Both endpoints of every relation must be contexts you have actually declared.

contextMapDecl
: 'contextmap' '{' relationDecl* '}'
;
relationDecl
: typeName relationArrow typeName ':' relationRole
( sharedKernelBlock | aclBlock )?
;
relationArrow
: '->' // directed: upstream on the left, downstream on the right
| '<->' // bidirectional
;
relationRole
: 'partnership'
| 'shared-kernel'
| 'customer-supplier'
| 'conformist'
| 'anti-corruption-layer'
| 'open-host'
| 'published-language'
;
sharedKernelBlock
: '{' typeName ( ',' typeName )* ','? '}'
;
aclBlock
: 'acl' '{' aclMapping+ '}'
;
aclMapping
: qualifiedType '->' qualifiedType
;
qualifiedType
: typeName '.' typeName
;

Each relationDecl is one line: an upstream context name, an arrow, a downstream context name, a colon, and a role. The optional trailing block is only meaningful on shared-kernel (a type list) or anti-corruption-layer (an acl mapping block) — attaching either to any other role is a validator error.

An integration event is declared inside a context (or module) body as a typeDecl. The two-word keyword integration event is followed by an identifier and a brace-delimited field list that follows the same member* grammar as value objects:

integrationEventDecl
: annotation* 'integration' 'event' Identifier '{' member* '}'
;

The annotation* prefix (@since(N), @deprecated("reason")) follows the same versioning annotation grammar described in Versioning (§18).

Publish and subscribe declarations appear inside a context body alongside other contextMember items:

publishDecl
: 'publishes' typeName
;
subscribeDecl
: 'subscribes' typeName '.' typeName
;

publishDecl names a local integration event; subscribeDecl gives the publisher context and event name as a dotted pair (Sales.OrderPlaced). Forward references are legal: publishes may appear before the integration event it names.

context Catalog { value Sku { code: String } }
context Sales { value Quote { n: Int } }
contextmap {
Catalog -> Sales : conformist
}

Use -> for a directed relation (upstream on the left, downstream on the right) and <-> for a bidirectional one:

contextmap {
Catalog <-> Sales : partnership
}

The map emits no C# type of its own — it drives validation and downstream emission. The compiler enforces:

SituationDiagnostic
Endpoint is not a declared contextContextMapUnknownContext
A context related to itself (A -> A)SelfRelation
The same pair declared twice (order-insensitive for <->)DuplicateContextRelation

In directory mode (koine build <dir>) every .koi file compiles as one model, so the map can live in its own map.koi file and still name contexts declared elsewhere. Multiple contextmap blocks — even across files — merge deterministically into one map.

Every relation carries exactly one of the seven classic strategic DDD roles, each spelled as a single token:

RoleWhat it does in Koine
partnershipTwo contexts succeed or fail together. Documentary; no auto-permit.
shared-kernelThe pair jointly owns a small set of types (see §17.4). Auto-permits references to the shared types.
customer-supplierUpstream supplies, downstream consumes. Authorizes subscriptions; does not auto-permit direct references (import the type).
conformistDownstream conforms to upstream’s model. Auto-permits a direct reference to upstream types.
anti-corruption-layerDownstream shields itself behind a translator (see §17.5).
open-hostUpstream publishes a service for anyone. Authorizes subscriptions.
published-languageUpstream commits to a stable contract. Documentary.

Two roles change how cross-context references resolve. With a conformist (or shared-kernel) relation, the downstream context can name an upstream type directly and the emitted file gets a precise using automatically — no import needed:

context Catalog { value Sku { code: String } }
context Sales { value Quote { sku: Sku } }
contextmap { Catalog -> Sales : conformist }

Sales/Quote.cs is emitted with using Catalog; and compiles. Remove the relation and the same field becomes an UnimportedReference error.

Three roles authorize subscriptions: open-host and customer-supplier let a downstream context subscribe to an upstream context’s published events. conformist does not — a subscribes over a conformist relation is a SubscribeNoRelation error.

An integration event is a published language — its fields must be cross-boundary-safe so the contract never leaks your internal model.

StatementRequirementDiagnostic if violated
publishes XX is an integration event in the same contextUnknownPublishedEvent
subscribes P.XP is a known contextSubscribeUnknownContext
subscribes P.XP actually publishes XSubscribeNotPublished
subscribes P.XAn open-host or customer-supplier relation P -> here existsSubscribeNoRelation

A shared-kernel relation can carry a brace-separated list of the types the two contexts jointly own. Each shared type is emitted once, into a dedicated kernel namespace, instead of being duplicated into both contexts:

context Sales {
value Money { amount: Decimal }
value Quote { price: Money }
}
context Shipping {
value Label { cost: Money }
}
contextmap {
Sales <-> Shipping : shared-kernel { Money }
}

Money lands in Sales__Shipping/Kernel/Money.cs under namespace Sales__Shipping.Kernel; — and every partner file that references it (Sales/Quote.cs, Shipping/Label.cs) gets a precise using Sales__Shipping.Kernel;. The kernel namespace is order-normalized: the two context names are joined alphabetically with a double underscore, so it is Sales__Shipping whether you write Sales <-> Shipping or Shipping <-> Sales.

The block is shared-kernel { TypeA, TypeB } — a comma-separated type list (trailing comma allowed). Constraints:

  • Only value objects and enums are shareable. Sharing an entity or aggregate is SharedKernelNotShareable.
  • An unknown type in the block is UnknownSharedKernelType; the same type shared across two kernels is SharedKernelTypeConflict.
  • Sharing an *Id (e.g. OrderId) is redundant — ids are already global and stay in their owner namespace rather than moving to the kernel.
  • A non-partner context that references the shared type still needs an import (or it raises UnimportedReference).
  • Attaching a { ... } type list to a non-kernel role parses, but reports SharedTypesOnNonKernel. Only put it on a shared-kernel relation.

An anti-corruption-layer relation can carry an acl { ... } block that maps upstream types to downstream types. Koine turns those mappings into a translator interface in the downstream context — the seam where you write the corruption-shielding glue by hand:

context Legacy {
value Account { reference: String }
value Charge { amount: Decimal }
}
context Billing {
value Customer { name: String }
value Invoice { total: Decimal }
}
contextmap {
Legacy -> Billing : anti-corruption-layer
acl { Legacy.Account -> Billing.Customer
Legacy.Charge -> Billing.Invoice }
}

17.5.2 Translation to C# (anti-corruption layer)

Section titled “17.5.2 Translation to C# (anti-corruption layer)”

The example above emits Billing/ILegacyToBillingTranslator.cs:

namespace Billing;
public interface ILegacyToBillingTranslator
{
Billing.Customer Translate(Legacy.Account source);
Billing.Invoice Translate(Legacy.Charge source);
}

The interface is named I<Upstream>To<Downstream>Translator, lives in the downstream namespace, and gets one fully-qualified Translate method per mapping.

  • Each mapping is Context.Type -> Context.Type — both sides must be dotted (fully qualified).
  • The source side must be an upstream type and the destination a downstream type, or it reports AclMappingType.
  • The acl { } block only belongs on an anti-corruption-layer role (AclOnNonAclRole otherwise), and an ACL relation with no block emits no translator.
  • Referencing an upstream type directly over an ACL relation is allowed but emits an AclDirectUpstreamReference warning — the point of an ACL is to translate, not to reach through.

Domain events stay inside their aggregate; an integration event is the cross-boundary contract a context broadcasts to the rest of the system. Declare one with the two-word keyword integration event:

context Sales {
integration event OrderPlaced {
orderId: OrderId
total: Decimal
placedAt: Instant
}
}

17.6.2 Translation to C# (integration event)

Section titled “17.6.2 Translation to C# (integration event)”

The declaration above emits a sealed record carrying the runtime marker IIntegrationEvent:

using Koine.Runtime;
namespace Sales;
public sealed record OrderPlaced : IIntegrationEvent
{
public OrderId OrderId { get; }
public decimal Total { get; }
public DateTimeOffset PlacedAt { get; }
public DateTimeOffset OccurredOn { get; init; } = DateTimeOffset.UtcNow;
// constructor sets each field
}

The Koine.Runtime.IIntegrationEvent marker file is emitted only when at least one integration event exists.

A context that owns an event declares publishes <Event>; a downstream context subscribes with subscribes <Publisher>.<Event>. Together with an authorizing relation, this is the full pub/sub triple:

context Sales {
publishes OrderPlaced
integration event OrderPlaced {
orderId: OrderId
total: Decimal
placedAt: Instant
}
}
context Shipping {
subscribes Sales.OrderPlaced
}
contextmap {
Sales -> Shipping : open-host
}

17.7.2 Translation to C# (subscriber handler)

Section titled “17.7.2 Translation to C# (subscriber handler)”

The subscriber gets a handler interface — the seam you implement to react to the event:

using System.Threading;
using System.Threading.Tasks;
using Sales;
namespace Shipping;
public interface IHandleOrderPlaced
{
Task Handle(Sales.OrderPlaced theEvent, CancellationToken ct = default);
}

The event type is fully qualified with the publisher’s namespace; the subscriber never re-emits the publisher’s record. A publish-only context (one that only publishes) gets no handler interface.

The Shop showcase wires all of this together across six contexts in a single context-map.koi:

contextmap {
// Shared kernel: Catalog and Ordering jointly own the Currency enum.
Catalog <-> Ordering : shared-kernel { Currency }
// Conformist: Shipping conforms to Catalog's published Weight.
Catalog -> Shipping : conformist
// Customer-supplier: Customers supplies the PostalAddress Shipping imports.
Customers -> Shipping : customer-supplier
// Open-host: Ordering publishes OrderPlaced; these authorize the subscriptions.
Ordering -> Shipping : open-host
Ordering -> Payments : open-host
// Partnership: Shipping and Payments coordinate to fulfil an order.
Shipping <-> Payments : partnership
// Anti-corruption layer: Payments translates the Legacy gateway's model.
Legacy -> Payments : anti-corruption-layer
acl {
Legacy.GatewayResult -> Payments.PaymentReceipt
}
}

The Ordering context publishes the event and both downstream contexts subscribe:

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

From that one map, the compiler emits Catalog__Ordering/Kernel/Currency.cs (the shared kernel), Payments/ILegacyToPaymentsTranslator.cs (the ACL translator), and an IHandleOrderPlaced.cs handler seam in both Shipping and Payments.

The same integration-event + context-map graph also drives a non-C# target: --target asyncapi emits a single AsyncAPI 3.0 document (asyncapi.yaml) describing the domain’s cross-boundary event API. It reads only the target-agnostic model, so it works on any model that declares integration events.

Terminal window
koine build ./Models --target asyncapi --out ./events

The mapping is mechanical and deterministic (channels, operations, and schemas are emitted in a stable order, so re-running is byte-identical):

Koine constructAsyncAPI 3.0 output
Each integration eventa channels/<Event> entry (addressed by the event name) and a components/messages/<Event>
Event doc comment (///)the message summary
Event fieldsa components/schemas/<Event>Payload JSON-Schema; non-optional fields are required
Primitive field (String/Int/Bool/Decimal/Instant)type: string/integer/boolean/string/string + format: date-time (Decimal stays a string to preserve precision)
enum fieldan inline type: string with the enum: member list
ID value object (*Id) fielda shared components/schemas/<Name> (type: string), referenced by $ref
A context that publishes the eventan operations/<Context>_send_<Event> with action: send
A context whose subscribes the map authorizesan operations/<Context>_receive_<Event> with action: receive
The bounded context owning an operationa tags entry on that operation

A model with no integration events still emits a minimal valid document (the info block with empty channels/operations). The emitter is target-agnostic — nothing AsyncAPI-specific lives in the semantic model — so it sits alongside the C#, TypeScript, Python, PHP, Rust, and docs back-ends.

Names are normally unqualified, but the compiler does not force integration-event names to be unique across contexts. When two contexts declare the same integration-event name, the channel, message, payload schema, and operation $refs for that name are context-qualified (<Context>_<Event>, e.g. Sales_OrderPlaced) so neither context’s contract is dropped; names declared in a single context keep their bare form.

An optional conformance check runs the AsyncAPI CLI over the emitted document when KOINE_ASYNCAPI_VALIDATE is set; it is skipped (INCONCLUSIVE) otherwise, so the build stays hermetic.