Appearance
Operations Framework: Events
Operations Framework supports producing events from commands. Events are stored in the database alongside the operation and processed asynchronously with guaranteed execution.
This is a pure Transactional Outbox implementation. Since Operations Framework already provides infrastructure for on-commit actions (operation logging, invalidation), events are a natural fit. The key benefit: if the transaction fails, the event is never committed or processed – you get atomic "business data + event" writes without distributed transactions.
Overview
Events enable:
- Asynchronous processing: Trigger actions after a command completes
- Delayed execution: Schedule events for future processing
- Deduplication: Prevent duplicate event processing via UUID-based conflict strategies
- Transactional safety: Events are stored in the same transaction as the operation – no event is processed if the transaction rolls back
DbEvent Entity
Events are persisted using the DbEvent entity:
cs
public class AppDbContext : DbContextBase
{
public DbSet<DbOperation> Operations => Set<DbOperation>();
public DbSet<DbEvent> Events => Set<DbEvent>(); // Required for events
public AppDbContext(DbContextOptions options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<DbOperation>().ToTable("_Operations");
modelBuilder.Entity<DbEvent>().ToTable("_Events");
}
}DbEvent Properties
| Property | Type | Description |
|---|---|---|
Uuid | string | Primary key (typically ULID or GUID) |
Version | long | Concurrency check field |
State | LogEntryState | New, Processed, or Discarded |
LoggedAt | DateTime | When the event was logged (UTC) |
DelayUntil | DateTime | Earliest processing time (UTC) |
ValueJson | string | Serialized event payload |
Database Indexes
DbEvent has two indexes for efficient querying:
sql
-- For finding events ready to process
CREATE INDEX IX_State_DelayUntil ON Events (State, DelayUntil);
-- For trimming old events
CREATE INDEX IX_DelayUntil_State ON Events (DelayUntil, State);Adding Events to Operations
Use the Operation.AddEvent() method to add events:
cs
[CommandHandler]
[DeferredInvalidationMode(DeferredInvalidationMode.Local)]
public virtual async Task<Order> CreateOrder(
CreateOrderCommand command, CancellationToken cancellationToken = default)
{
var context = CommandContext.GetCurrent();
await using var dbContext = await DbHub.CreateOperationDbContext(cancellationToken);
var order = new Order { /* ... */ };
dbContext.Orders.Add(order);
await dbContext.SaveChangesAsync(cancellationToken);
// Add an event to be processed after commit
context.Operation.AddEvent(new OrderCreatedEvent(order.Id, order.CustomerId));
Invalidation.Defer(() => _ = GetOrder(command.OrderId, default));
return order;
}Removing events
An event can also be taken off an operation before it commits, which is useful when a handler decides partway through that it shouldn't fire after all:
cs
var @event = operation.AddEvent(new OrderCreatedEvent(order.Id));
// ... and later, on some condition:
operation.RemoveEvent(@event); // or RemoveEvent(@event.Uuid)
operation.RemoveEvents(x => x.Value is OrderCreatedEvent);
operation.RemoveEvents(); // all of themRemoveEvent returns whether it removed anything. Like AddEvent, all of these need an active, non-transient scope.
OperationEvent Properties
| Property | Type | Description |
|---|---|---|
Uuid | string | Unique identifier (auto-generated if not set) |
Value | object? | Event payload (must be serializable) |
LoggedAt | Moment | Timestamp when event was logged |
DelayUntil | Moment | When the event should be processed |
UuidConflictStrategy | KeyConflictStrategy | How to handle UUID conflicts |
Fluent Configuration
OperationEvent supports fluent configuration:
cs
var @event = context.Operation.AddEvent(new OrderCreatedEvent(order.Id, order.CustomerId))
.SetDelayBy(TimeSpan.FromMinutes(5)) // Process 5 minutes later
.SetUuidConflictStrategy(KeyConflictStrategy.Skip); // Skip if duplicate UUIDDelayed Events
Events can be scheduled for future processing:
cs
// Process immediately
context.Operation.AddEvent(new ImmediateEvent());
// Process after 5 minutes
context.Operation.AddEvent(new DelayedEvent())
.SetDelayBy(TimeSpan.FromMinutes(5));
// Process at specific time
context.Operation.AddEvent(new ScheduledEvent())
.SetDelayUntil(SystemClock.Instance.Now + TimeSpan.FromHours(1));Delay Quantization
For rate limiting, you can collapse many triggers onto one coarse schedule. Passing a quantum rounds DelayUntil up onto a lattice and puts the resulting instant into the UUID, so repeated producers of the same logical event deduplicate:
cs
// Align to 1-minute cells (useful for rate limiting)
context.Operation.AddEvent(new RateLimitedEvent())
.SetDelayUntil(
SystemClock.Instance.Now,
TimeSpan.FromMinutes(1), // Quantum
"rate-limit" // UUID prefix: the dedup key, and the offset's source
);This creates events with UUIDs like rate-limit-at-{timestamp} and uses KeyConflictStrategy.Skip, so one cell of the lattice holds at most one event per prefix.
The lattice is offset by a hash of the UUID prefix, not aligned to the quantum itself. Aligning every prefix to the same boundary means every hourly job in the system fires at the top of the hour – the schedule becomes a storm. Keying the offset off the prefix spreads them across the quantum while keeping everything that shares a prefix on one instant, which is what the UUID needs in order to work as a deduplication key. The hash is XxHash3, so it's identical on every host and in every process; string.GetHashCode() would not be.
The consequence worth knowing: the cell seam sits at the prefix's offset rather than at a round boundary, so two producers aiming at instants an hour apart in wall-clock terms may land in different cells, and two aiming within the same cell will land together. Producers that must deduplicate have to agree on the instant they target, not merely on the hour.
To name the offset yourself – TimeSpan.Zero restores the boundary-aligned lattice – pass it after the quantum:
cs
// The same, but on a lattice aligned to the quantum itself - every prefix fires
// on the boundary, which is what produces the storm this offset exists to avoid
context.Operation.AddEvent(new RateLimitedEvent())
.SetDelayUntil(
SystemClock.Instance.Now,
TimeSpan.FromMinutes(1),
TimeSpan.Zero, // The offset, named instead of derived
"rate-limit");TimeSpanExt.GetHashBasedOffset(source, unit) is the derivation itself, if you need to predict where a prefix lands or reuse the same spreading elsewhere. A zero or negative quantum means no quantization: the instant passes through and the UUID names it exactly, matching nothing else.
UUID Conflict Strategies
When an event UUID already exists, the conflict strategy determines behavior:
| Strategy | Behavior |
|---|---|
Fail | Throw an exception (default) |
Update | Update the existing event if State == New |
Skip | Silently skip the duplicate |
cs
// Example: Ensure only one notification per user per hour
context.Operation.AddEvent(new NotificationEvent(userId))
.SetUuid($"notify-{userId}-{DateTime.UtcNow:yyyy-MM-dd-HH}")
.SetUuidConflictStrategy(KeyConflictStrategy.Skip);Event Processing
DbEventProcessor
Events with ICommand values are processed by DbEventProcessor:
cs
public record OrderCreatedEventCommand(long OrderId, long CustomerId) : ICommand<Unit>;
// This command will be executed when the event is processed
public class OrderEventProcessor : IComputeService
{
[CommandHandler]
public virtual async Task OnOrderCreated(
OrderCreatedEventCommand command, CancellationToken cancellationToken = default)
{
// Send notification, update analytics, etc.
await SendOrderConfirmation(command.OrderId, cancellationToken);
}
private Task SendOrderConfirmation(long orderId, CancellationToken ct) => Task.CompletedTask;
}The processor:
- Reads events where
State == New && DelayUntil <= now - Deserializes the event value
- If value is
ICommand, executes it viaCommander.Call(command, true, cancellationToken) - Updates event state to
ProcessedorDiscarded
Event States
Event Log Reader
DbEventLogReader is a background service that processes events:
cs
db.AddOperations(operations => {
operations.ConfigureEventLogReader(_ => new() {
CheckPeriod = TimeSpan.FromSeconds(5).ToRandom(0.1),
BatchSize = 64,
ConcurrencyLevel = Environment.ProcessorCount * 4,
});
});Default Settings
| Setting | Default | Description |
|---|---|---|
CheckPeriod | 5s ± 0.5s | How often to check for new events |
BatchSize | 64 | Events processed per batch |
ConcurrencyLevel | 4 × CPU count | Concurrent event processing |
ReprocessPolicy | 5 retries, 5min timeout | Retry policy for failed events |
ReprocessDelay | 100ms ± 100ms | Delay between retries |
Event Log Trimmer
DbEventLogTrimmer removes old processed events:
cs
db.AddOperations(operations => {
operations.ConfigureEventLogTrimmer(_ => new() {
MaxEntryAge = TimeSpan.FromHours(1), // Keep events for 1 hour
CheckPeriod = TimeSpan.FromMinutes(15).ToRandom(0.25),
});
});Trimming Condition
Events are trimmed when:
DelayUntil <= (now - MaxEntryAge)ANDState != New(i.e., Processed or Discarded)
This ensures:
- New events are never trimmed
- Recently processed events are kept for debugging
- Old processed events are cleaned up automatically
Transient Operations and Events
Important: Transient operations (in-memory only) cannot have events:
cs
// This will throw TransientScopeOperationCannotHaveEvents!
if (operation.Scope.IsTransient) {
operation.AddEvent(new SomeEvent()); // ERROR!
}Events require database persistence because:
- They're stored in the same transaction as the operation
- They're processed asynchronously after commit
- They need guaranteed delivery across host restarts
If you need to trigger actions from in-memory commands, use:
IOperationCompletionListenerfor synchronous post-commit actions- A separate database command that produces the event
Event Log Watcher
Unlike operations, events use LocalDbLogWatcher by default because events are:
- Processed by any available host (not necessarily the originating one)
- Not replicated across hosts (each host processes events independently)
- Designed for asynchronous background work
The local watcher is sufficient because events don't need cross-host notification – the DbEventLogReader on each host polls the database for new events.
Custom Event Values
Events can carry any JSON-serializable payload:
cs
// Simple value
context.Operation.AddEvent(new StringEvent("User created"));
// Complex object
context.Operation.AddEvent(new UserCreatedEvent {
UserId = user.Id,
Email = user.Email,
CreatedAt = DateTime.UtcNow,
});
// Command (will be executed)
context.Operation.AddEvent(new SendWelcomeEmailCommand(user.Id, user.Email));IHasUuid Interface
If your event value implements IHasUuid, the UUID is extracted automatically:
cs
public record OrderEvent(string Uuid, long OrderId) : IHasUuid;
// Usage: UUID is taken from the event value
public static class OrderEventUsage
{
public static void Example(CommandContext context, long orderId)
=> context.Operation.AddEvent(new OrderEvent($"order-{orderId}", orderId));
}Best Practices
- Use commands as event values for automatic processing
- Set appropriate delays to avoid overwhelming downstream systems
- Use UUID conflict strategies for idempotency
- Keep event payloads small – they're serialized to JSON
- Don't rely on event ordering – events may be processed out of order
- Handle failures gracefully – events may be retried multiple times
