Documentation / Patterns

Notifications and background work

Applications often turn an accepted domain event into something a person sees: an in-app notice, a reminder, or an email. Those outcomes have different durability and access requirements. Model them explicitly instead of putting all work into an event handler with a provider call.

On this page

A notification fact is data that a reader can query. A provider send is an external effect. A scheduled job is future work with its own execution identity. Rebuilding a read model or activating an actor should not accidentally repeat any of them.

Project notification facts

A notification projection can create one row per intended recipient and source event. Store the recipient, notification kind, source identity, current status, and the minimum content or reference required for the reader.

The sample helper creates a stable recipient-specific key from source stream/version and a versioned notification kind:

public static class NotificationIdentity
{
    public static string ForRecipient(ProjectionSource source, Guid recipientId)
    {
        string[] fields = ["registration-notification-v1", source.Stream,
            source.Version.ToString(System.Globalization.CultureInfo.InvariantCulture), recipientId.ToString("D")];
        return Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(fields)));
    }
}

A production writer uses a unique constraint and commits this key with its notification row. The reader still authorizes the current recipient. If the row only references a registration, reread that registration through its current access boundary before exposing protected details.

The acting user and recipient need not match. An administrator may initiate a cancellation that notifies an attendee. Keep attribution in metadata and recipient identity in the domain notification record; do not overwrite one to represent the other.

Send through durable effect state

The framework's intent protocol requires a committed event receipt and an idempotent provider:

public static class ConfirmationEffects
{
    public static Task<ExternalEffectIntent> RegisterAsync(IExternalEffectIntentStore store,
        IIdempotentExternalEffectProvider provider, AtomicEventTransactionReceipt receipt, byte[] payload) =>
        ExternalEffectDispatcher.RegisterAsync(store, provider, receipt,
            "registration-confirmation", "confirmation-worker", payload);
}

This helper applies when the application is using the compatible receipt/intent path. An ordinary projection that creates its own notification outbox needs its own transaction and worker contract; it should not manufacture an atomic event receipt.

A worker dispatches pending intents with the retained key and records a definitive terminal result. A timeout or uncertain result remains recoverable. If an application records an intent only after the event commit, it must handle the gap between those steps; a capable atomic event/effect store can commit them together under its stronger contract.

Schedule later work with a new scope

A reminder tomorrow runs after the original command's transaction has ended. Keep the durable reminder/job identity and causal trigger, then construct current execution context when it runs:

public static Metadata Scheduled(Metadata trigger, Guid currentUserId) =>
    new(Guid.NewGuid(), currentUserId, Guid.Empty)
    {
        CausationId = trigger.MessageId,
        TransactionId = null
    };

This example chooses a new correlation for the later execution and preserves a causal reference. Recheck user or service authority at execution time. A completed transaction ID should not be reused as if it were still active.

An in-memory timer is useful for waking local work, but the work's pending state must be durable if it must survive process loss. Orleans reminders and the Troolio desired-state effect provider have their own configuration and host capability requirements. See external effects and reliable jobs.

Plan the failure cases

If a confirmation was sent but the worker crashed before recording its receipt, a retry should use the same provider key. If access was revoked before the send, current policy may suppress it or produce a definitive superseded outcome. If a recipient's view is rebuilt from old events, its retained notification identity should prevent duplicate facts or sends.

Rate limits, retry backoff, maximum attempt age, dead-letter outcomes, and provider credentials belong to the application worker. Monitor pending age and definitive outcomes, and provide a way to investigate one source without replaying every user's history.

Continue with orchestration workflows, idempotency and ordering, and observability.