Domain Events

IDomainEvent interface, outbox pattern, OutboxInterceptor, OutboxProcessor, and reliable event delivery.

Domain events represent significant occurrences in the business domain. SCRIPE uses the Outbox Pattern to guarantee reliable event delivery — events are persisted in the same database transaction as entity changes and published asynchronously by a background processor.

IDomainEvent Interface

All domain events implement the IDomainEvent interface, which inherits from SCRIPE mediator's INotification. This enables in-process pub/sub where multiple handlers can subscribe to the same event type. Each event carries a unique EventId and OccurredAt timestamp.

Core.Domain/Events/IDomainEvent.csC#
/// <summary>
/// Marker interface for domain events. Enables event-driven architecture.
/// Domain events stay domain-pure; the Application layer wraps them for dispatch.
/// </summary>
public interface IDomainEvent
{
    DateTime OccurredOn { get; }
    Guid EventId { get; }
}

public abstract record DomainEvent : IDomainEvent
{
    public DateTime OccurredOn { get; } = DateTime.UtcNow;
    public Guid EventId { get; } = Guid.NewGuid();
}

Publishing & Handling Flow

Domain events follow a 6-step lifecycle: the entity raises an event via RaiseDomainEvent(), the OutboxInterceptor captures it during SaveChanges, the event is persisted as an OutboxMessage in the same transaction, the OutboxProcessor polls for unprocessed messages, deserializes the event, and publishes it via SCRIPE mediator.

Domain Event Flow
Entity.RaiseDomainEvent()
SaveChanges
OutboxInterceptor — Captures events before SaveChanges
same DB transaction
OutboxMessage Table — Persisted in same transaction
polls unprocessed
OutboxProcessor — Background job, polls every 5s
deserialize & publish
AstraFlow mediator Publish
fan-out
IDomainEventHandler<T> — One or more handlers

IDomainEventPublisher

IDomainEventPublisher.csC#
/// <summary>
/// Abstraction for publishing domain events.
/// Default implementation uses AstraFlow mediator for in-process pub/sub.
/// </summary>
public interface IDomainEventPublisher
{
    Task PublishAsync<TEvent>(TEvent domainEvent, CancellationToken ct = default)
        where TEvent : IDomainEvent;
    Task PublishAllAsync(IEnumerable<IDomainEvent> events, CancellationToken ct = default);
}

Outbox Pattern

The Outbox Pattern solves the dual-write problem: how to atomically update the database AND publish an event. By persisting events in the same transaction as entity changes, we guarantee that events are never lost — even if the application crashes immediately after SaveChanges. During PostgreSQL Identity schema upgrades, historical outbox and inbox rows are moved into the identity schema; conflicting outbox payloads stop the migration for operator review.

Committed Entity-Change Auditing

Entity-change audit snapshots are written to the owning module's OutboxMessages table with the business mutation, so rollback removes both. The outbox processor delivers the snapshot to the central Identity audit store at least once. The event ID is the AuditLog ID; a redelivery is treated as complete only when that exact ID and tenant already exist, including when a worker has no tenant query scope. Delivery is at-least-once, not exactly-once; failures remain retryable.

Core.Domain/Events/EntityChangeAuditCommittedEvent.csC#
/// <summary>
/// Carries a committed entity-change audit record through the owning module's transactional outbox.
/// The event ID is the deterministic central audit-log ID, making redelivery idempotent.
/// </summary>
public sealed record EntityChangeAuditCommittedEvent(
    Guid EventId,
    DateTime OccurredOn,
    AuditLog AuditLog) : IDomainEvent;

OutboxMessage Entity

Core.Infrastructure/Outbox/OutboxMessage.csC#
/// <summary>
/// Represents a domain event persisted in the outbox table.
/// Ensures reliable event delivery even if the app crashes after SaveChanges.
/// </summary>
public class OutboxMessage
{
    public Guid Id { get; set; }
    public string Type { get; set; } = string.Empty;     // Full type name
    public string Content { get; set; } = string.Empty;  // JSON-serialized event
    public DateTime OccurredOnUtc { get; set; }
    public DateTime? ProcessedOnUtc { get; set; }         // null = unprocessed
    public string? Error { get; set; }                    // Error message if failed
    public int RetryCount { get; set; }                   // Number of processing attempts
}

OutboxInterceptor

The OutboxInterceptor is an EF Core SaveChanges interceptor that runs BEFORE the transaction is committed. It collects all domain events from tracked entities, serializes them as OutboxMessage records, and adds them to the same database context — ensuring atomicity.

Core.Infrastructure/Outbox/OutboxInterceptor.csC#
/// <summary>
/// EF Core SaveChanges interceptor that captures domain events
/// and persists them to the Outbox table in the SAME transaction.
/// This guarantees atomicity: entity changes + events are committed together.
/// </summary>
public class OutboxInterceptor : SaveChangesInterceptor
{
    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken ct = default)
    {
        var context = eventData.Context!;

        // 1. Collect all domain events from tracked entities
        var entities = context.ChangeTracker
            .Entries<Entity<Guid>>()
            .Where(e => e.Entity.DomainEvents.Any())
            .ToList();

        var domainEvents = entities
            .SelectMany(e => e.Entity.DomainEvents)
            .ToList();

        // 2. Clear events from entities (prevent re-processing)
        entities.ForEach(e => e.Entity.ClearDomainEvents());

        // 3. Convert domain events → OutboxMessages
        var outboxMessages = domainEvents.Select(evt => new OutboxMessage
        {
            Id = Guid.NewGuid(),
            Type = evt.GetType().AssemblyQualifiedName!,
            Content = JsonSerializer.Serialize(evt, evt.GetType()),
            OccurredOnUtc = DateTime.UtcNow,
        });

        // 4. Add to outbox table (same transaction as entity changes)
        await context.Set<OutboxMessage>().AddRangeAsync(outboxMessages, ct);

        return await base.SavingChangesAsync(eventData, result, ct);
    }
}

OutboxProcessor

The OutboxProcessor is a BackgroundService that polls the OutboxMessage table every 5 seconds for unprocessed messages. It processes them in batches of 20, deserializing each event and publishing it via SCRIPE mediator. Failed events are retried with an incrementing RetryCount.

Core.Infrastructure/Outbox/OutboxProcessor.cs — SimplifiedC#
/// <summary>
/// Background service that polls the Outbox table for unprocessed messages
/// and publishes them via AstraFlow mediator. Runs every 5 seconds.
/// </summary>
public class OutboxProcessor : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            using var scope = _serviceScopeFactory.CreateScope();
            var dbContext = scope.ServiceProvider.GetRequiredService<OutboxDbContext>();
            var publisher = scope.ServiceProvider.GetRequiredService<IDomainEventPublisher>();

            // 1. Get unprocessed messages (batch of 20)
            var messages = await dbContext.OutboxMessages
                .Where(m => m.ProcessedOnUtc == null)
                .OrderBy(m => m.OccurredOnUtc)
                .Take(20)
                .ToListAsync(ct);

            foreach (var message in messages)
            {
                try
                {
                    // 2. Deserialize event
                    var type = Type.GetType(message.Type)!;
                    var domainEvent = (IDomainEvent)JsonSerializer
                        .Deserialize(message.Content, type)!;

                    // 3. Publish via AstraFlow mediator
                    await publisher.PublishAsync(domainEvent, ct);

                    // 4. Mark as processed
                    message.ProcessedOnUtc = DateTime.UtcNow;
                }
                catch (Exception ex)
                {
                    message.Error = ex.Message;
                    message.RetryCount++;
                }
            }

            await dbContext.SaveChangesAsync(ct);
            await Task.Delay(TimeSpan.FromSeconds(5), ct);
        }
    }
}

Outbox Cleanup Job

A background recurring job (e.g., via Hangfire) runs daily at 2:00 AM UTC to delete processed outbox messages older than 7 days. This prevents unbounded table growth while keeping recent messages for debugging.

OutboxCleanupJob.csC#
/// <summary>
/// Recurring job that deletes processed outbox messages older than 7 days.
/// Runs daily at 2:00 AM UTC via Hangfire.
/// </summary>
public class OutboxCleanupJob : RecurringJobBase
{
    public override string JobId => "outbox-cleanup";
    public override string CronExpression => "0 2 * * *"; // Daily at 2 AM

    public override async Task ExecuteAsync(CancellationToken ct)
    {
        var cutoff = DateTime.UtcNow.AddDays(-7);
        var deleted = await _dbContext.OutboxMessages
            .Where(m => m.ProcessedOnUtc != null && m.ProcessedOnUtc < cutoff)
            .ExecuteDeleteAsync(ct);

        _logger.LogInformation("Cleaned up {Count} outbox messages", deleted);
    }
}

Outbox Architecture Summary

ComponentFileResponsibility
OutboxMessageOutbox/OutboxMessage.csEntity representing a persisted domain event
OutboxInterceptorOutbox/OutboxInterceptor.csEF interceptor — captures events in same transaction
OutboxDbContextOutbox/OutboxDbContext.csDedicated DbContext for the outbox table
OutboxProcessorOutbox/OutboxProcessor.csBackground service — polls and publishes events
OutboxCleanupJobOutbox/OutboxCleanupJob.csHangfire recurring job — cleans processed messages
IDomainEventPublisherEvents/IDomainEventPublisher.csAbstraction for publishing domain events
ScripeDomainEventPublisherEvents/ScripeDomainEventPublisher.csIn-process pub/sub via AstraFlow mediator
DomainEventNotificationEvents/DomainEventNotification.csWrapper to bridge IDomainEvent → INotification

Creating Custom Domain Events

Follow these 3 steps to add a new domain event to SCRIPE. The outbox infrastructure handles persistence and delivery automatically.

  1. 1. Define the Event

    Create a record implementing IDomainEvent in the module's Domain/Events/ directory. Include all contextual data that handlers will need.
    Identity.Domain/Events/AdminCreatedEvent.csC#
    // 1. Define the event
    public record AdminCreatedEvent(
        Guid AdminId,
        string Email,
        string TenantId
    ) : IDomainEvent;
  2. 2. Raise from Command Handler

    Call entity.RaiseDomainEvent(new YourEvent(...)) in the command handler, then call SaveChangesAsync. The OutboxInterceptor captures the event automatically.
    CreateAdminCommandHandler.csC#
    // 2. Raise the event from entity/command handler
    public class CreateAdminCommandHandler : ICommandHandler<CreateAdminCommand, Guid>
    {
        public async Task<Result<Guid>> Handle(CreateAdminCommand command, CancellationToken ct)
        {
            var admin = new Admin { /* ... */ };
            await _repository.AddAsync(admin, ct);
    
            // Raise domain event
            admin.RaiseDomainEvent(new AdminCreatedEvent(
                admin.Id, admin.Email, admin.TenantId.ToString()
            ));
    
            await _repository.SaveChangesAsync(ct);
            // OutboxInterceptor captures the event automatically!
            return Result.Success(admin.Id);
        }
    }
  3. 3. Create Event Handlers

    Implement INotificationHandler<DomainEventNotification> to react to the event. Multiple handlers can subscribe to the same event for different side-effects (email, audit, webhook, etc.).
    SendWelcomeEmailOnAdminCreated.csC#
    // 3. Handle the event (one or more handlers)
    public class SendWelcomeEmailOnAdminCreated
        : INotificationHandler<DomainEventNotification>
    {
        public async Task Handle(DomainEventNotification notification, CancellationToken ct)
        {
            if (notification.DomainEvent is not AdminCreatedEvent evt) return;
    
            await _emailService.SendAsync(new EmailMessage
            {
                To = evt.Email,
                Template = "welcome-admin",
                Variables = new { Name = evt.Email }
            });
        }
    }

Reliability Guarantees

  • ✅ With Outbox Pattern

    • Events persisted in same DB transaction as entity changes
    • Survives application crashes — events are in the database
    • At-least-once delivery guaranteed
    • Automatic retry with configurable retry count
    • Cleanup job removes old processed messages
    • Events processed in order (by OccurredOnUtc)
  • ❌ Without Outbox Pattern

    • Events published in-memory — lost if app crashes
    • No guarantee of delivery after SaveChanges
    • Race condition: DB saved but event publish fails
    • No retry mechanism for failed handlers
    • No auditing of which events were processed
    • Order not guaranteed in async scenarios