ums

ADR-0051: Event Bus — Injectable Port Strategy (.NET / MassTransit)

Status

Accepted

Date

2026-05-15

Context

Evolith ADR-0015 mandates an injectable messaging abstraction that decouples domain logic from any specific broker technology. UMS publishes domain events across 8 bounded contexts (identity, authorization, configuration, audit, approvals, IGA, compliance, console). Without a defined port, domain code would reference MassTransit or RabbitMQ APIs directly — violating the Hexagonal Architecture enforced by Evolith ADR-0011 and documented in UMS CP-01.

Additionally, ADR-0050 defines the CloudEvents type convention (ums.{bounded-context}.{entity}.{past-participle}). This ADR defines the runtime port and adapter that publishes events using that convention.

Key constraints:


Decision

Adopt IEventBusPort as the single injectable abstraction for all domain event publishing, implemented by a MassTransit adapter for production and an in-memory adapter for testing.

1. Port Definition (Domain Layer)

// src/UMS.Domain/Ports/IEventBusPort.cs
namespace UMS.Domain.Ports;

public interface IEventBusPort
{
    Task PublishAsync<TEvent>(TEvent domainEvent, CancellationToken ct = default)
        where TEvent : IDomainEvent;
}

The port lives in the Domain layer. No reference to MassTransit, RabbitMQ, or any infrastructure type is allowed here.

2. CloudEvents Envelope (Infrastructure Layer)

All outbound messages are wrapped in a CloudEvents 1.0 envelope before dispatch:

// src/UMS.Infrastructure/EventBus/CloudEventEnvelope.cs
public sealed record CloudEventEnvelope<TData>
{
    public string SpecVersion { get; } = "1.0";
    public string Id { get; } = Guid.NewGuid().ToString();
    public string Source { get; init; } = "ums";
    public string Type { get; init; }           // ums.{context}.{entity}.{past-participle}
    public string DataContentType { get; } = "application/json";
    public DateTimeOffset Time { get; } = DateTimeOffset.UtcNow;
    public TData Data { get; init; }
}

3. MassTransit Adapter (Production)

// src/UMS.Infrastructure/EventBus/MassTransitEventBusAdapter.cs
internal sealed class MassTransitEventBusAdapter : IEventBusPort
{
    private readonly IPublishEndpoint _endpoint;
    private readonly ICloudEventTypeResolver _typeResolver;

    public MassTransitEventBusAdapter(
        IPublishEndpoint endpoint,
        ICloudEventTypeResolver typeResolver)
    {
        _endpoint = endpoint;
        _typeResolver = typeResolver;
    }

    public async Task PublishAsync<TEvent>(TEvent domainEvent, CancellationToken ct = default)
        where TEvent : IDomainEvent
    {
        var cloudEventType = _typeResolver.Resolve<TEvent>();
        var envelope = new CloudEventEnvelope<TEvent>
        {
            Type = cloudEventType,
            Source = "ums",
            Data = domainEvent,
        };
        await _endpoint.Publish(envelope, ct);
    }
}

4. CloudEvent Type Resolver

// src/UMS.Infrastructure/EventBus/CloudEventTypeResolver.cs
public interface ICloudEventTypeResolver
{
    string Resolve<TEvent>() where TEvent : IDomainEvent;
}

internal sealed class AttributeCloudEventTypeResolver : ICloudEventTypeResolver
{
    public string Resolve<TEvent>() where TEvent : IDomainEvent
    {
        var attr = typeof(TEvent).GetCustomAttribute<CloudEventTypeAttribute>()
            ?? throw new InvalidOperationException(
                $"{typeof(TEvent).Name} must declare [CloudEventType(\"ums.context.entity.verb\")]");
        return attr.Type;
    }
}

[AttributeUsage(AttributeTargets.Class)]
public sealed class CloudEventTypeAttribute : Attribute
{
    public string Type { get; }
    public CloudEventTypeAttribute(string type) => Type = type;
}

Usage on domain events:

[CloudEventType("ums.identity.user.registered")]
public sealed record UserRegisteredEvent(Guid UserId, string Email, Guid TenantId) : IDomainEvent;

5. In-Memory Adapter (Tests)

// src/UMS.Infrastructure/EventBus/InMemoryEventBusAdapter.cs
public sealed class InMemoryEventBusAdapter : IEventBusPort
{
    private readonly List<IDomainEvent> _published = new();
    public IReadOnlyList<IDomainEvent> Published => _published.AsReadOnly();

    public Task PublishAsync<TEvent>(TEvent domainEvent, CancellationToken ct = default)
        where TEvent : IDomainEvent
    {
        _published.Add(domainEvent);
        return Task.CompletedTask;
    }
}

6. DI Registration

// src/UMS.Infrastructure/DependencyInjection.cs
public static IServiceCollection AddEventBus(
    this IServiceCollection services,
    IConfiguration config)
{
    services.AddSingleton<ICloudEventTypeResolver, AttributeCloudEventTypeResolver>();

    services.AddMassTransit(x =>
    {
        x.SetKebabCaseEndpointNameFormatter();
        x.UsingRabbitMq((ctx, cfg) =>
        {
            cfg.Host(config["EventBus:Host"], config["EventBus:VirtualHost"], h =>
            {
                h.Username(config["EventBus:Username"]);
                h.Password(config["EventBus:Password"]);
            });
            cfg.ConfigureEndpoints(ctx);
        });
    });

    services.AddScoped<IEventBusPort, MassTransitEventBusAdapter>();
    return services;
}

For test projects:

services.AddSingleton<InMemoryEventBusAdapter>();
services.AddSingleton<IEventBusPort>(sp => sp.GetRequiredService<InMemoryEventBusAdapter>());

7. Transactional Outbox Integration

Per TE-04, IEventBusPort is not called directly from command handlers. The pattern is:

  1. Command handler calls aggregate.Raise(new UserRegisteredEvent(...)) — event stored in memory
  2. UnitOfWork.CommitAsync() persists the entity AND the domain events to the outbox_messages table in the same SQL transaction
  3. MassTransit Outbox processor reads outbox_messages and calls IEventBusPort.PublishAsync for each pending event
  4. Marks message as processed once broker ACK is received

This guarantees exactly-once delivery under database failure.

8. CloudEvent Type Registry (per ADR-0050)

Domain Event CloudEvent type
UserRegisteredEvent ums.identity.user.registered
UserActivatedEvent ums.identity.user.activated
UserBlockedEvent ums.identity.user.blocked
DocumentExpiredEvent ums.compliance.document.expired
PromotionRequestApprovedEvent ums.iga.promotion-request.approved
ApprovalRequestApprovedEvent ums.approvals.approval-request.approved
PermissionMutatedEvent ums.authorization.permission.mutated
ProfileAssignedToUserEvent ums.authorization.profile.assigned

Consequences

Positive

Negative


ADR Registry Evolith ADR-0015 TE-04: Transactional Outbox