Skip to content

Events and locks

Domain events stay inside one app. Integration events cross app and service boundaries; they use the same outbox, so an event is only sent when the change that caused it is saved.

Integration events

Add Coworkee.EventBus and depend on CoworkeeEventBusModule. Put the events in a contracts assembly that publisher and subscribers reference.

public sealed record OrderShipped(Guid OrderId, string Number) : IIntegrationEvent;

Publish from any handler through IEventBus. The event goes into the outbox of the current unit of work and is sent after the command saved:

internal sealed class ShipOrderHandler(CoworkeeDbContext db, IEventBus bus) : IHandler<ShipOrder, Result>
{
    public async Task<Result> HandleAsync(ShipOrder command, CancellationToken cancellationToken)
    {
        var order = await db.Set<Order>().SingleAsync(o => o.Id == command.Id, cancellationToken);
        order.Ship();
        await bus.PublishAsync(new OrderShipped(order.Id, order.Number), cancellationToken);
        return Result.Success();
    }
}

Handle it in any app; AddMessagingFromAssembly finds the handlers like all others:

internal sealed class CreateInvoice(CoworkeeDbContext db) : IIntegrationEventHandler<OrderShipped>
{
    public Task HandleAsync(OrderShipped integrationEvent, CancellationToken cancellationToken)
    {
        db.Add(new Invoice { OrderId = integrationEvent.OrderId });
        return Task.CompletedTask;
    }
}
Topic Behavior
Delivery The outbox processor (AddCoworkeeOutboxProcessing<TContext>()) sends the event. Without a transport package the handlers of the same app get it directly.
User Handlers run as the user and tenant that published the event.
Idempotency Each handler runs in its own scope and transaction. Its changes are saved together with a row in cw."InboxMessages" (message id, handler); a redelivered message skips handlers that already processed it. The handler does not need to call SaveChangesAsync, the inbox saves its changes.
Retries A failing handler does not stop the others. In process, the outbox retries the message with backoff, up to 5 times; then it stays in cw."OutboxMessages" with its error.

RabbitMQ

Add Coworkee.EventBus.RabbitMq and depend on CoworkeeRabbitMqEventBusModule. It connects with the rabbitmq connection string or Coworkee:EventBus:RabbitMq:ConnectionString.

Setting (Coworkee:EventBus:RabbitMq) Default Meaning
Exchange coworkee.events direct exchange, routing key is the event type
Queue entry assembly name queue of this app: instances of one app share it, every other app needs its own
MaxDeliveries 5 attempts before a message moves to {Queue}.dead
Prefetch 16 unacknowledged messages per consumer

The outbox only marks a message as sent when the broker confirmed it. Each app binds its quorum queue to the event types it has handlers for. A failing message goes back to the end of the queue with an attempt counter; once it reaches MaxDeliveries it lands in {Queue}.dead for inspection. Events nobody subscribed to yet are dropped by the broker.

Distributed locks

IDistributedLock keeps work from running twice across instances, for example a recurring job started manually while the scheduled run is still going:

await using var held = await locks.AcquireAsync("invoice-run", TimeSpan.Zero, cancellationToken);
if (held is null)
{
    return; // another instance is on it
}

AcquireAsync waits up to the timeout and returns null when the lock stayed taken. Disposing the handle releases it.

Provider Setup Notes
Database lock default with AddCoworkeeDbContext Postgres advisory lock or SQL Server sp_getapplock on a connection of its own; a crashed holder frees it when the connection drops. SQLite uses a file lock, enough for the processes of one machine
Redis Coworkee:DistributedLock:Provider = Redis with Coworkee.Realtime and a redis connection string expires after 30 s unless the holder is alive and extends it

The notification digest uses it, so a manual run during the scheduled one sends no second mail.