8. Integration Events¶
Domain events (Chapter 6) stay inside Lending. But when someone borrows a book, Catalog needs to know — the available copies should decrease. That's a cross-context event. In Deepstaging, these are integration events: durable, named events that flow between runtimes through a shared Contracts project.
The Topic Class¶
Remember Library.Contracts? Time to add events:
[IntegrationEventTopic]
public sealed class LendingEvents
{
[IntegrationEvent("lending.book-borrowed")]
public sealed record BookBorrowed(BookId BookId, PatronId PatronId);
[IntegrationEvent("lending.book-returned")]
public sealed record BookReturned(BookId BookId, PatronId PatronId);
}
A topic class groups events by the context that publishes them. [IntegrationEventTopic] marks the container. Each event gets a stable string identity via [IntegrationEvent("...")] — this is the wire name used for serialization across transports.
Both BookId and PatronId are in Contracts already (Chapter 1). This is why we put all IDs there from the start.
Publishing (Lending Side)¶
[IntegrationEvents(typeof(LendingEvents))]
public static partial class LendingIntegrationEvents;
Then update BorrowBook and ReturnBook to publish integration events alongside domain events:
from _ in LendingStore.Loans.Save<LendingRuntime>(loan)
>> LendingDomainEvents.Enqueue<LendingRuntime>(new DomainEvent.BookBorrowed(cmd.BookId, cmd.PatronId))
>> LendingIntegrationEvents.Enqueue<LendingRuntime>(new LendingEvents.BookBorrowed(cmd.BookId, cmd.PatronId))
select id;
Domain event stays in Lending (updates patron loan count). Integration event crosses to Catalog (updates available copies). Same publish pattern, different destinations.
Subscribing (Catalog Side)¶
[IntegrationEvents(typeof(LendingEvents))]
public static partial class CatalogIntegrationEvents;
[IntegrationEventHandler<CatalogRuntime>(typeof(LendingEvents))]
public static partial class CatalogLendingEventHandlers
{
public static Eff<CatalogRuntime, Unit> Handle(LendingEvents.BookBorrowed evt) =>
CatalogDispatch.UpdateInventory(evt.BookId, -1).AsUnit();
public static Eff<CatalogRuntime, Unit> Handle(LendingEvents.BookReturned evt) =>
CatalogDispatch.UpdateInventory(evt.BookId, 1).AsUnit();
}
[IntegrationEventHandler<CatalogRuntime>(typeof(LendingEvents))] subscribes this class to all events in the LendingEvents topic. Each Handle method matches by type. When a BookBorrowed event arrives, Catalog decrements the book's available copies by dispatching UpdateInventory.
Notice: the handler delegates to CatalogDispatch — it doesn't manipulate the store directly. Same dispatch layer, same validation, same effects pipeline.
How It Flows¶
BorrowBook (Lending)
├── Save loan
├── Domain event → LendingDomainEventHandlers (update patron count)
└── Integration event → CatalogLendingEventHandlers (update book inventory)
In development, integration events flow through in-process channels — just like domain events. In production, you'd swap to Azure Service Bus or RabbitMQ with one line in your Program.cs. The domain code doesn't change.
Test Cross-Context Events¶
With Create(), you can verify integration events were published:
[Test]
public async Task BorrowBook_PublishesIntegrationEvent()
{
var runtime = TestLendingRuntime.Create();
var patronId = PatronId.New();
var bookId = BookId.New();
runtime.PatronStore.Seed(new Patron { Id = patronId, Name = "Diana", Email = "diana@example.com" });
await LendingDispatch.BorrowBook(patronId, bookId).RunAsync(runtime);
var evt = runtime.LendingIntegrationEvents.AssertContains<LendingEvents.BookBorrowed>();
await Assert.That(evt.BookId).IsEqualTo(bookId);
}
With CreateConfigured(), you can test the full flow through both contexts — but that's a topic for docs/guides/.