Appearance
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 → ClickHouse1. 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:
- Publish an integration event via
IModuleEventBus(for cross-module consumers) - 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 transactionThe DomainEventDispatcher resolves handlers using the open generic pattern:
csharp
var handlerType = typeof(IDomainEventHandler<>).MakeGenericType(domainEvent.GetType());
var handlers = serviceProvider.GetServices(handlerType);Edge Cases
| Scenario | Approach |
|---|---|
| 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 event | Handler only calls auditOutbox.Stage() |
| Domain event with no audit | Handler 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 hereConsumers 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
| Method | When to use | Transactionality |
|---|---|---|
auditOutbox.Stage() | Inside domain event handlers or before SaveChangesAsync | Atomic — 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 policies6. 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:
AuditOutboxProcessorretries failed batch inserts - Ordering: Audit events are processed in insertion order
- Immutability: ClickHouse
audit_logstable 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