Skip to content

Event-Driven Design

Microtec uses Azure Service Bus (ASB) as its message broker with MassTransit as the abstraction layer. This page documents the event-driven patterns, interfaces, and implementation conventions used across all backend microservices.


Architecture Overview


From RabbitMQ to Azure Service Bus

Migration Context (ADR-005)

Microtec originally used RabbitMQ (self-hosted on a VM) for async messaging. The platform migrated to Azure Service Bus for operational simplicity, native Azure integration, and built-in dead-letter queues, FIFO ordering, and session support. MassTransit's abstraction layer made this migration transparent to most service code — only the transport configuration changed.

Key ASB tier choices:

EnvironmentASB TierReason
dev / stage / preprodStandardLower cost; no sessions required
uat / productionPremiumVNet integration, large messages, sessions

MassTransit Configuration

csharp
// In Program.cs (via Microtec.Web.Hosting)
builder.Services.AddMassTransit(cfg =>
{
    cfg.SetKebabCaseEndpointNameFormatter();

    // Register all consumers in this assembly
    cfg.AddConsumers(typeof(ApplicationAssemblyMarker).Assembly);

    cfg.UsingAzureServiceBus((context, asbCfg) =>
    {
        asbCfg.Host(builder.Configuration["ServiceBus:ConnectionString"]);

        // Apply retry and dead-letter policy globally
        asbCfg.UseMessageRetry(r => r.Exponential(
            retryLimit: 5,
            minInterval: TimeSpan.FromSeconds(1),
            maxInterval: TimeSpan.FromSeconds(30),
            intervalDelta: TimeSpan.FromSeconds(5)));

        asbCfg.ConfigureEndpoints(context);
    });
});

Integration Events

Base Event Class

csharp
// In Microtec.Domain package

/// <summary>
/// Base class for all integration events published to Azure Service Bus.
/// </summary>
public abstract record IntegrationEvent
{
    /// <summary>Unique event identifier for idempotency.</summary>
    public Guid EventId { get; init; } = Guid.NewGuid();

    /// <summary>UTC timestamp when the event was created.</summary>
    public DateTime OccurredAt { get; init; } = DateTime.UtcNow;

    /// <summary>The tenant that this event belongs to.</summary>
    public Guid TenantId { get; init; }
}

Example Integration Event

csharp
// In AppsPortal.Application/Invoice/Events/InvoiceCreatedEvent.cs

public record InvoiceCreatedEvent : IntegrationEvent
{
    public Guid InvoiceId { get; init; }
    public string InvoiceNumber { get; init; } = default!;
    public Guid CustomerId { get; init; }
    public decimal TotalAmount { get; init; }
    public DateOnly InvoiceDate { get; init; }
}

Publishing Events

IEventPublisher Interface

csharp
public interface IEventPublisher : IScopedService
{
    Task PublishAsync<TEvent>(TEvent integrationEvent, CancellationToken ct = default)
        where TEvent : IntegrationEvent;

    Task SendAsync<TCommand>(TCommand command, string queueName, CancellationToken ct = default)
        where TCommand : class;
}

Publishing in a Command Handler

csharp
internal sealed class AddInvoiceCommandHandler
    : ICommandHandler<AddInvoiceCommand, Guid>
{
    private readonly IUnitOfWork<AccountingDbContext> _uow;
    private readonly IEventPublisher _eventPublisher;

    public async Task<Guid> Handle(
        AddInvoiceCommand request,
        CancellationToken cancellationToken)
    {
        var invoice = Invoice.Create(/* ... */);

        await _uow.Repository<Invoice>().AddAsync(invoice, cancellationToken);
        await _uow.SaveChangesAsync(cancellationToken);

        // Publish after successful DB save (outbox pattern)
        await _eventPublisher.PublishAsync(new InvoiceCreatedEvent
        {
            InvoiceId = invoice.Id,
            InvoiceNumber = invoice.InvoiceNumber,
            CustomerId = invoice.CustomerId,
            TotalAmount = invoice.TotalAmount,
            InvoiceDate = invoice.InvoiceDate,
            TenantId = invoice.TenantId
        }, cancellationToken);

        return invoice.Id;
    }
}

Publish After SaveChanges

Always publish integration events after SaveChangesAsync() succeeds. Publishing before a failed save creates phantom events. For critical workflows, consider the transactional outbox pattern.


Consuming Events

IBaseConsumer Interface and QueueName Attribute

csharp
/// <summary>
/// Marker interface for all MassTransit consumers.
/// Implementations are auto-discovered via AddConsumers().
/// </summary>
public interface IBaseConsumer<TMessage> : IConsumer<TMessage>
    where TMessage : class
{ }

/// <summary>
/// Decorates a consumer class with the queue/topic subscription name.
/// Used by the endpoint configuration to bind consumers to the correct ASB entity.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class QueueNameAttribute : Attribute
{
    public string Name { get; }
    public QueueNameAttribute(string name) => Name = name;
}

Consumer Example: ZATCA Submission

csharp
// In Integration.Application/Zatca/Consumers/InvoiceCreatedConsumer.cs

[QueueName(RabbitMqQueues.InvoiceProcessing)]
public class InvoiceCreatedConsumer : IBaseConsumer<InvoiceCreatedEvent>
{
    private readonly IZatcaSubmissionService _zatcaService;
    private readonly ITenantContextManager _tenantManager;
    private readonly ILogger<InvoiceCreatedConsumer> _logger;

    public InvoiceCreatedConsumer(
        IZatcaSubmissionService zatcaService,
        ITenantContextManager tenantManager,
        ILogger<InvoiceCreatedConsumer> logger)
    {
        _zatcaService = zatcaService;
        _tenantManager = tenantManager;
        _logger = logger;
    }

    public async Task Consume(ConsumeContext<InvoiceCreatedEvent> context)
    {
        var evt = context.Message;

        // Restore tenant context from event (no HTTP request scope)
        await _tenantManager.SetTenantByIdAsync(evt.TenantId, context.CancellationToken);

        _logger.LogInformation(
            "Processing ZATCA submission for invoice {InvoiceId} in tenant {TenantId}",
            evt.InvoiceId, evt.TenantId);

        await _zatcaService.SubmitInvoiceAsync(evt.InvoiceId, context.CancellationToken);
    }
}

Tenant Context in Consumers

Consumers run outside an HTTP request context. The TenantId embedded in every IntegrationEvent is used to restore the tenant context via ITenantContextManager.SetTenantByIdAsync() before any tenant-scoped operations.


Queue Name Constants

All queue and topic names are defined in a shared constants class to prevent typos:

csharp
// In Microtec.Domain package

public static class RabbitMqQueues
{
    // The class is named RabbitMqQueues for historical reasons (pre-ASB migration)
    // All values correspond to Azure Service Bus queue/topic names

    public const string InvoiceProcessing = "invoice-processing";
    public const string ZatcaSubmission   = "zatca-submission";
    public const string EtaSubmission     = "eta-submission";
    public const string NotificationSend  = "notification-send";
    public const string WorkflowTrigger   = "workflow-trigger";
    public const string ReportGenerate    = "report-generate";
    public const string TenantProvisioned = "tenant-provisioned";
    public const string UserCreated       = "user-created";
}

Naming History

The constants class retains the name RabbitMqQueues for backward compatibility with existing consumer attributes ([QueueName(RabbitMqQueues.InvoiceProcessing)]). The actual message broker is Azure Service Bus, not RabbitMQ.


Retry and Dead-Letter Policy

MassTransit's retry policy is applied globally:

AttemptDelay
1 (initial)Immediate
21 second
3~2.2 seconds
4~5 seconds
5~11 seconds
6Dead-letter queue

After exhausting retries, the message is moved to the dead-letter queue ({queue-name}/$deadletter in ASB). The Platforms Worker service has a dead-letter monitor job that alerts the operations team via the Notification Service.


Domain Events vs Integration Events

Microtec uses two event types that are sometimes confused:

Domain EventsIntegration Events
ScopeWithin a single serviceAcross service boundaries
TransportIn-memory (MediatR INotification)Azure Service Bus (MassTransit)
TimingBefore SaveChangesAsyncAfter SaveChangesAsync
Failure handlingRolls back with transactionDead-letter queue + retry
ExampleInvoiceLineAddedEvent triggers totals recalcInvoiceCreatedEvent triggers ZATCA submission
csharp
// Domain Event (in-memory, raised within the same service)
public record InvoiceLineAddedEvent(Guid InvoiceId, decimal NewTotal)
    : INotification;

// Integration Event (ASB, crosses service boundaries)
public record InvoiceCreatedEvent : IntegrationEvent
{
    public Guid InvoiceId { get; init; }
    // ...
}

Internal Documentation — Microtec Platform Team