CtrlK
BlogDocsLog inGet started
Tessl Logo

add-integration-event

Publish a cross-module integration event via the Outbox and handle it idempotently in another module. Use when one module must react to something that happened in another. See .agents/rules/eventing.md.

61

Quality

72%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Fix and improve this skill with Tessl

tessl review fix ./.agents/skills/add-integration-event/SKILL.md
SKILL.md
Quality
Evals
Security

Add Integration Event

Cross-module communication goes through integration events + the Outbox (transactional, crash-safe) — never a direct in-process call into another module's runtime, and never IEventBus.PublishAsync from a handler. Full model: .agents/rules/eventing.md.

Step 1 — Define the event (source module's Contracts)

Modules.{Source}.Contracts/Events/{Event}IntegrationEvent.cs — implement IIntegrationEvent:

public sealed record {Event}IntegrationEvent(
    Guid Id,
    DateTime OccurredOnUtc,
    string? TenantId,
    string CorrelationId,
    string Source,
    Guid {Entity}Id,
    string SomePayload) : IIntegrationEvent;

⚠️ Don't rename/move this type later — the outbox stores its assembly-qualified name; a rename makes Type.GetType() return null and the message dead-letters. Keep the type name + namespace stable.

Step 2 — Publish via the Outbox (source handler)

Publishing needs no module registration — the outbox is framework-owned and the host wires it once. Inject IOutboxWriter (from FSH.Framework.Eventing.Abstractions) and add the event in the same unit of work:

public sealed class Do{Thing}CommandHandler({Source}DbContext db, IOutboxWriter outbox)
    : ICommandHandler<Do{Thing}Command, Unit>
{
    public async ValueTask<Unit> Handle(Do{Thing}Command command, CancellationToken cancellationToken)
    {
        // … mutate entities, db.SaveChangesAsync …
        var evt = new {Event}IntegrationEvent(
            Id: Guid.CreateVersion7(),
            OccurredOnUtc: DateTime.UtcNow,
            TenantId: /* current tenant */,
            CorrelationId: Guid.NewGuid().ToString(),
            Source: "{Source}",
            {Entity}Id: entity.Id,
            SomePayload: "…");
        await outbox.AddAsync(evt, cancellationToken).ConfigureAwait(false);
        return Unit.Value;
    }
}

The OutboxDispatcherHostedService later publishes it via IEventBus.

Step 3 — Handle it (consumer module)

Modules.{Consumer}/IntegrationEventHandlers/{Event}IntegrationEventHandler.cssealed, implement IIntegrationEventHandler<T>:

public sealed class {Event}IntegrationEventHandler({Consumer}DbContext db /*, IHubContext<AppHub> hub */)
    : IIntegrationEventHandler<{Event}IntegrationEvent>
{
    public async Task HandleAsync({Event}IntegrationEvent @event, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(@event);
        // … write to the consumer's tables / push a notification …
        await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
    }
}

Register the consumer's handlers in its ConfigureServices:

builder.Services.AddIntegrationEventHandlers(typeof({Consumer}Module).Assembly);

Gotchas

  • Idempotency is free with the in-memory bus (the Inbox dedups by {eventId, handlerName}) — don't hand-roll it.
  • The in-memory bus runs handlers synchronously in the publisher's scope — keep the handler lean; a throw surfaces to the originating request. Published via the outbox, that scope belongs to the dispatcher, so the consumer runs on the next cycle and its failures never reach the caller. Don't let a caller (or a test) assume the side effect already happened; integration tests drain with OutboxDrain.DrainAsync.
  • If the handler reads a tenant-filtered DbContext from a background path (open-generic handler, Hangfire job), restore Finbuckle context first via IMultiTenantContextSetter (see WebhookFanoutHandler).
  • Module load order: the consumer must load before the publisher if it must react (Order in [assembly: FshModule]) — e.g. Notifications (750) before Chat (800).

Checklist

  • Event in source Contracts, implements IIntegrationEvent, stable type name
  • Published via IOutboxWriter.AddAsync (not the bus); no per-module eventing registration needed
  • Consumer handler sealed : IIntegrationEventHandler<T>; AddIntegrationEventHandlers(assembly) registered
  • Background readers restore tenant context; module Order lets the consumer load first
  • Build + tests green
Repository
fullstackhero/dotnet-starter-kit
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.