Inter-Module Communication — Contracts and Events
Enable communication between modules in a modular monolith: synchronous module APIs, in-process domain events, the Module Event Bus pattern, and preventing the distributed monolith trap through loose coupling.
Two Communication Styles
Synchronous (query):
Module A directly calls Module B's public service interface.
Returns data immediately.
Use when: Module A needs data from Module B to fulfill a request.
Asynchronous (event):
Module A publishes an event when something happens.
Module B subscribes and reacts in its own time.
Use when: Module A doesn't need to know who reacts.
Both happen in-process in a modular monolith.
(In microservices, events would go through a message bus — here they don't.)Synchronous Module API Call
// Prescriptions module needs patient data to display prescription summaries
// Depends on IPatientQueryService (Patients.Api) — NOT on Patients.Domain
public sealed class GetPrescriptionWithPatientHandler
: IRequestHandler<GetPrescriptionQuery, Result<PrescriptionDetailDto>>
{
private readonly IPrescriptionRepository _repo;
private readonly IPatientQueryService _patients; // cross-module dependency on API interface
public GetPrescriptionWithPatientHandler(
IPrescriptionRepository repo,
IPatientQueryService patients)
{
_repo = repo;
_patients = patients;
}
public async Task<Result<PrescriptionDetailDto>> Handle(
GetPrescriptionQuery query, CancellationToken ct)
{
var prescription = await _repo.GetByIdAsync(query.PrescriptionId, ct);
if (prescription is null) return Result.Failure<PrescriptionDetailDto>(DomainErrors.Prescription.NotFound);
// Cross-module call via interface — both resolved from same DI container (in-process)
var patient = await _patients.GetByIdAsync(prescription.PatientId.Value, ct);
return Result.Success(new PrescriptionDetailDto(
prescription.Id.Value,
prescription.MedicationName,
patient?.Mrn ?? "UNKNOWN",
patient?.FullName ?? "Unknown Patient"));
}
}In-Process Module Event Bus
// Module Event Bus: publish events to other modules within the same process
// Backed by MediatR IPublisher — no serialization, no network
public interface IModuleEventBus
{
Task PublishAsync<TEvent>(TEvent @event, CancellationToken ct = default)
where TEvent : class, IModuleEvent;
}
public sealed class MediatrModuleEventBus : IModuleEventBus
{
private readonly IPublisher _publisher;
public MediatrModuleEventBus(IPublisher publisher) => _publisher = publisher;
public Task PublishAsync<TEvent>(TEvent @event, CancellationToken ct = default)
where TEvent : class, IModuleEvent
=> _publisher.Publish(@event, ct);
}
// Events that cross module boundaries
// Note: these are "module integration events", not domain events
// They use primitive types (Guid, string) — no domain types cross boundaries
public sealed record PatientAdmittedModuleEvent(
Guid PatientId,
string Mrn,
Guid WardId,
DateTime AdmittedAt) : IModuleEvent, INotification;
// Patients module publishes when a patient is admitted:
await _eventBus.PublishAsync(new PatientAdmittedModuleEvent(
patient.Id.Value, patient.Mrn.Value, wardId.Value, DateTime.UtcNow), ct);
// Prescriptions module subscribes:
public sealed class CreateWardPrescriptionScheduleOnAdmission
: INotificationHandler<PatientAdmittedModuleEvent>
{
public async Task Handle(PatientAdmittedModuleEvent @event, CancellationToken ct)
{
// React to patient admission — create a default medication schedule
await _prescriptionScheduler.CreateDefaultScheduleAsync(
@event.PatientId, @event.WardId, ct);
}
}Avoiding Circular Module Dependencies
Circular dependency: Module A depends on Module B, Module B depends on Module A.
A → B → A: impossible to resolve in DI.
Prevention rules:
1. Dependencies should flow one direction: High-level modules depend on low-level.
Patients ← Prescriptions ← LabResults ← Billing (← = "depends on")
2. Use events for the reverse direction (avoid direct dependency):
Billing needs to know about a Prescription → subscribe to PrescriptionCreatedModuleEvent
Billing does NOT call Prescriptions API (that would create a cycle)
3. Move truly shared things to SharedKernel (Result, Error, base types)
If you find a cycle:
→ Extract the shared concept into SharedKernel
→ Invert the dependency using an event
→ Merge the two modules (maybe they are one bounded context)Outbox Pattern for Reliable Cross-Module Events
// Problem: module publishes event AFTER SaveChanges — but if publish fails, event is lost
// Solution: write event to outbox table in the same transaction
// In module's DbContext SaveChanges override:
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
var domainEvents = ChangeTracker.Entries<IHasDomainEvents>()
.SelectMany(e => e.Entity.DomainEvents)
.ToList();
// Convert domain events to outbox messages (in the same transaction)
foreach (var domainEvent in domainEvents)
{
ModuleOutboxMessages.Add(new OutboxMessage(
EventType: domainEvent.GetType().Name,
Payload: JsonSerializer.Serialize(domainEvent),
CreatedAt: DateTime.UtcNow));
}
var result = await base.SaveChangesAsync(ct);
// Background worker publishes outbox messages via IModuleEventBus
return result;
}Production issue I've seen: Two modules (Prescriptions and LabResults) called each other's public service APIs. Prescriptions called LabResults to check INR before approving a dose. LabResults called Prescriptions to get the patient's current medication list. A circular dependency: Prescriptions → LabResults → Prescriptions. The DI container detected the cycle at startup and threw. The fix was an event: LabResults published
InrResultRecordedModuleEvent, and Prescriptions subscribed — no direct call from LabResults to Prescriptions needed.
Key Takeaway
Synchronous calls go through module API interfaces — never through domain types or infrastructure directly. Asynchronous events cross module boundaries as
IModuleEventrecords with primitive types. Events break circular dependencies: instead of Module B calling Module A, Module A publishes an event that Module B subscribes to. Use an outbox pattern for reliable event delivery within the same transaction. Circular module dependencies indicate a wrong boundary — merge or invert with events.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.