Appearance
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:
| Environment | ASB Tier | Reason |
|---|---|---|
| dev / stage / preprod | Standard | Lower cost; no sessions required |
| uat / production | Premium | VNet 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:
| Attempt | Delay |
|---|---|
| 1 (initial) | Immediate |
| 2 | 1 second |
| 3 | ~2.2 seconds |
| 4 | ~5 seconds |
| 5 | ~11 seconds |
| 6 | Dead-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 Events | Integration Events | |
|---|---|---|
| Scope | Within a single service | Across service boundaries |
| Transport | In-memory (MediatR INotification) | Azure Service Bus (MassTransit) |
| Timing | Before SaveChangesAsync | After SaveChangesAsync |
| Failure handling | Rolls back with transaction | Dead-letter queue + retry |
| Example | InvoiceLineAddedEvent triggers totals recalc | InvoiceCreatedEvent 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; }
// ...
}