Skip to content

Request Lifecycle

Complete flow of a request from the HTTP endpoint to ClickHouse audit storage.

Overview

HTTP Request
  → Middleware (Serilog, Auth, AuditContext)
    → Endpoint (IEndpoint.MapEndpoint)
      → ValidationFilter<T>
        → Handler (HandleAsync)
          → Business logic + entity.AddDomainEvent(...)
          → db.SaveChangesAsync()
            → OutboxInterceptor:
              1. Harvest domain events from ChangeTracker
              2. Dispatch to IDomainEventHandler<T>
                 → IModuleEventBus.PublishAsync() → IEventConsumer<T> (in-process, synchronous)
                 → Stage audit entries (IAuditOutbox)
              3. INSERT audit outbox_messages (atomic with entity changes)
            → Transaction commits
          → [Background] AuditOutboxProcessor → ClickHouse

1. Endpoint Anatomy

Each endpoint is a sealed class implementing IEndpoint with a static MapEndpoint method. Request and response types are nested records inside the endpoint class.

csharp
public sealed class CreateUserEndpoint : IEndpoint
{
    public sealed record CreateUserRequest(string Email, string Username);
    public sealed record CreateUserResponse(Guid Id, string Email);

    public static void MapEndpoint(IEndpointRouteBuilder app)
    {
        app.MapPost("/api/users", HandleAsync)
            .WithTags("Users")
            .RequireAuthorization(Permissions.Users.Create)
            .WithValidation<CreateUserRequest>();
    }

    private static async Task<IResult> HandleAsync(
        CreateUserRequest request, CreateUserHandler handler, CancellationToken ct)
    {
        var result = await handler.HandleAsync(request, ct);
        return result.IsSuccess
            ? TypedResults.Created($"/api/users/{result.Value.Id}", result.Value)
            : result.Error.ToHttpResult();
    }
}

The ValidationFilter<T> runs FluentValidation rules before the handler executes. If validation fails, a 400 Bad Request is returned with the validation errors.


2. Handler Pattern

Handlers are plain classes with a HandleAsync method. No interfaces, no MediatR, no CQRS. Scrutor auto-registers them by name convention (*Handler).

csharp
internal sealed class CreateUserHandler(IdentityDbContext db, IClock clock)
{
    public async Task<Result<CreateUserEndpoint.CreateUserResponse>> HandleAsync(
        CreateUserEndpoint.CreateUserRequest request, CancellationToken ct)
    {
        var user = new User { Email = request.Email, Username = request.Username };

        // Raise domain event (dispatched during SaveChangesAsync)
        user.AddDomainEvent(new UserCreatedDomainEvent(
            Id: Guid7.NewGuid().Value,
            OccurredAt: clock.GetCurrentInstant(),
            UserId: user.Id,
            Email: user.Email,
            Username: user.Username,
            CreatedBy: /* actor ID */));

        db.Users.Add(user);
        await db.SaveChangesAsync(ct);

        return new CreateUserEndpoint.CreateUserResponse(user.Id, user.Email);
    }
}

3. Domain Events

Domain events are internal to a module. They represent something that happened within the module's domain. Defined as sealed record implementing IDomainEvent.

csharp
namespace Wilo.Modules.Identity.Events.Users;

internal sealed record UserCreatedDomainEvent(
    Guid Id,
    Instant OccurredAt,
    Guid UserId,
    string Email,
    string Username,
    Guid CreatedBy) : IDomainEvent;

Raising Domain Events

Entities inherit AddDomainEvent() from the Entity base class:

csharp
user.AddDomainEvent(new UserCreatedDomainEvent(...));

Domain events are NOT dispatched immediately. They are stored in the entity's DomainEvents collection until SaveChangesAsync is called.

Domain Event Handlers

Each domain event handler implements IDomainEventHandler<T> and lives in the DomainEventHandlers/ folder. Handlers typically:

  1. Publish an integration event via IModuleEventBus (for cross-module consumers)
  2. Stage an audit entry via IAuditOutbox (for ClickHouse)
csharp
internal sealed class UserCreatedDomainEventHandler(
    IModuleEventBus eventBus,
    IAuditOutbox auditOutbox) : IDomainEventHandler<UserCreatedDomainEvent>
{
    public Task HandleAsync(UserCreatedDomainEvent domainEvent, CancellationToken ct)
    {
        auditOutbox.Stage(new AuditEventEnvelope
        {
            Id = Guid7.NewGuid().Value,
            OccurredAt = domainEvent.OccurredAt,
            Action = AuditAction.Users.Created,
            Module = "Identity",
            EntityType = "User",
            EntityId = domainEvent.UserId.ToString(),
            Severity = AuditSeverity.Info,
            Description = $"User '{domainEvent.Email}' created",
        });

        return Task.CompletedTask;
    }
}

Dispatch Mechanism

The OutboxInterceptor (a SaveChangesInterceptor) handles domain event dispatch:

db.SaveChangesAsync()
  → OutboxInterceptor.SavingChangesAsync():
    1. Scan ChangeTracker.Entries<Entity>() for entities with DomainEvents
    2. Collect all domain events, then call ClearDomainEvents() on each entity
    3. DomainEventDispatcher.DispatchAllAsync(domainEvents)
       → For each event, resolve IDomainEventHandler<T> from DI via reflection
       → Invoke HandleAsync() on each handler
       → Handlers call eventBus.PublishAsync() → IEventConsumer<T> invoked in-process (synchronous)
       → Handlers call auditOutbox.Stage()
    4. INSERT audit OutboxMessage rows (same DB transaction)
  → base.SavingChangesAsync() — EF Core saves entities + audit outbox in one transaction

The DomainEventDispatcher resolves handlers using the open generic pattern:

csharp
var handlerType = typeof(IDomainEventHandler<>).MakeGenericType(domainEvent.GetType());
var handlers = serviceProvider.GetServices(handlerType);

Edge Cases

ScenarioApproach
Entity-less events (e.g., failed login)Use manual eventBus.PublishAsync() + auditOutbox.PublishAsync() directly in the handler
One-time operations (e.g., platform setup)Keep explicit audit calls in the handler
Domain event with no integration eventHandler only calls auditOutbox.Stage()
Domain event with no auditHandler only calls eventBus.PublishAsync()

4. Integration Events

Integration events cross module boundaries. They are defined in Wilo.Contracts/{Module}/Events/ as sealed record implementing IIntegrationEvent.

csharp
namespace Wilo.Contracts.Identity.Events;

public sealed record UserRegisteredEvent(
    Guid Id,
    Instant OccurredAt,
    int Version,
    Guid UserId,
    string Email,
    string Username) : IIntegrationEvent;

In-Process Dispatch

Integration events are dispatched synchronously in-process via InProcessEventBus. When a domain event handler (or a feature handler directly) calls eventBus.PublishAsync(event, ct), InProcessEventBus resolves all registered IEventConsumer<T> implementations from the DI container and invokes each one synchronously within the same request scope — zero latency, zero DB rows.

csharp
// InProcessEventBus dispatches to all consumers immediately
await eventBus.PublishAsync(new UserRegisteredEvent { ... }, ct);
// → MemberRegisteredConsumer.ConsumeAsync() called synchronously here

Consumers must be idempotent since the framework does not provide automatic retry for integration events. If a consumer throws, the exception propagates to the caller's SaveChangesAsync and the transaction is rolled back.


5. Audit Pipeline

Staging vs Publishing

MethodWhen to useTransactionality
auditOutbox.Stage()Inside domain event handlers or before SaveChangesAsyncAtomic — persisted in same transaction
auditOutbox.PublishAsync()Entity-less events (no SaveChangesAsync)Immediate — fire-and-forget

Audit Event Structure

csharp
new AuditEventEnvelope
{
    Id = Guid7.NewGuid().Value,
    OccurredAt = domainEvent.OccurredAt,
    Action = AuditAction.Auth.Register,      // Typed constant
    Module = "Identity",
    EntityType = "User",
    EntityId = userId.ToString(),
    ActorId = actorUserId,                   // Who performed the action
    IpAddress = ipAddress,                   // Optional
    UserAgent = userAgent,                   // Optional
    Severity = AuditSeverity.Info,           // Info, Warning, Critical
    Description = "User 'john@example.com' registered",
}

Flow to ClickHouse

auditOutbox.Stage(envelope)
  → Stored in audit outbox table (same transaction)
  → AuditOutboxProcessor (BackgroundService) polls
  → Batch inserts into ClickHouse audit_logs table (MergeTree engine)
  → Immutable storage with retention policies

6. Audit Outbox Pattern

The outbox pattern is used exclusively for the audit pipeline. Integration events are dispatched in-process via InProcessEventBus — they are never persisted to the DB. Audit events are persisted transactionally and forwarded to ClickHouse by AuditOutboxProcessor.

Guarantees

  • Atomicity: Entity changes and audit outbox messages are committed in the same database transaction
  • At-least-once delivery to ClickHouse: AuditOutboxProcessor retries failed batch inserts
  • Ordering: Audit events are processed in insertion order
  • Immutability: ClickHouse audit_logs table uses a MergeTree engine — records cannot be modified or deleted

Audit Outbox Table

A single outbox_messages table in the public schema (owned by WiloDbContext) holds staged audit events. This is separate from business modules — modules no longer have their own outbox_messages tables.


7. Complete Flow Example

Scenario: User registers via POST /api/auth/register

1. RegisterEndpoint receives HTTP POST
2. ValidationFilter validates RegisterRequest
3. RegisterHandler.HandleAsync():
   a. Checks email uniqueness
   b. Creates User entity
   c. user.AddDomainEvent(new UserRegisteredDomainEvent(...))
   d. db.Users.Add(user)
   e. db.SaveChangesAsync()

4. OutboxInterceptor.SavingChangesAsync():
   a. Harvests UserRegisteredDomainEvent from user.DomainEvents
   b. Clears user.DomainEvents
   c. DomainEventDispatcher dispatches to UserRegisteredDomainEventHandler:
      - InProcessEventBus.PublishAsync(new UserRegisteredEvent(...))
        → MemberRegisteredConsumer.ConsumeAsync() invoked synchronously in-process
      - auditOutbox.Stage(new AuditEventEnvelope { Action = "auth.register", ... })
   d. INSERT INTO public.outbox_messages (audit event, same transaction)

5. Transaction commits (User row + audit OutboxMessage row — atomic)

6. [Background] AuditOutboxProcessor:
   a. Reads staged audit entry
   b. Batch inserts into ClickHouse audit_logs