Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20264 min read
Modular MonolithModule CommunicationDomain EventsASP.NET Core.NETArchitecture
Share:𝕏

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

C#
// 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

C#
// 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

C#
// 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 IModuleEvent records 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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.