Extension Seams

The sanctioned places you can extend BackWave (a custom store, dashboard surfaces, optional store capabilities, and egress observers), and why there is deliberately no in-path filter pipeline.


BackWave exposes a small, fixed set of extension points rather than a general-purpose plugin framework. Each seam sits at a boundary the system controls (a custom store behind the Storage Contract, dashboard surfaces contributed by a separately-installed package, optional store capabilities discovered at runtime, and egress-only lifecycle observers), and none of them can reach inside the Core's decision path. This page catalogs the sanctioned seams, shows how each is wired, and explains why there is deliberately no handler-filter or middleware pipeline. Cross-cutting concerns belong in job handlers or in idiomatic .NET DI decorators, never in an in-path interceptor.

The shape of BackWave's extensibility#

The organizing principle is the determinism boundary, where the Core decides and only the Shell acts. Extension points are permitted on the Shell and egress side, and at the storage boundary, but never inside the Core's decision path. That single rule explains both what exists and what is permanently rejected. Every seam below either lives beyond the boundary (a store implementation, verified against a contract rather than simulated) or downstream of an already-recorded decision (a dashboard render, an egress reaction). A seam that could change what the Core decides is not offered at all.

There are five public seams, plus one shape that is rejected by design.

Seam (public type)Package / namespaceWhat you supplyRegistration entry pointCan it alter a Core decision?
IJobStore (custom Storage Adapter)BackWave.StorageA full store honoring the Storage ContractBackWaveBuilder.UseStore(...)No; it is the boundary, verified by the Conformance Suite
IStoreFaultClassifier (optional capability)BackWave.StorageIsTransientFault(Exception) classificationImplement it on your IJobStore; detected at runtimeNo; only influences retry-vs-fail-stop of a store fault
IWakeUpHintSource (optional capability)BackWave.StorageA best-effort Wake-Up Hint channelImplement it on your IJobStore; detected at runtimeNo; only makes a worker poll sooner
IDashboardExtensionBackWave.DashboardNav, banner, page routes, action routesservices.AddBackWaveDashboardExtension<T>()No; the free Dashboard owns permission, antiforgery, SSE, redirects
ITransitionObserverBackWave.ObserversAn idempotent egress reaction to a transitionbw.AddObservers(...)No; egress-only, cannot veto or rewrite a transition
(rejected) state-election interceptorn/an/an/aWould, which is exactly why it is permanently rejected

Seam 1: custom stores behind the Storage Contract#

IJobStore is the single persistence seam between the engine and a backing store. Implementing it is how you write a custom Storage Adapter, and correctness is judged by the Conformance Suite rather than the simulator. A custom store lives beyond the determinism boundary, so it is verified against a contract rather than replayed. The v1 first-party adapters are Postgres, SQL Server, and SQLite (the Embedded Adapter, a full IJobStore and a supported production target), and a custom adapter is any type that honors the same contract.

The hard invariant, enforced by contract rather than by the type system, is that an implementation must never read its own clock for a semantic decision. Every time-dependent method takes the current instant as an explicit now parameter, so the store's behavior is fully determined by its inputs. That is what keeps the engine above it simulable.

The surface is large and semantically demanding, not simple CRUD. It spans enqueue, atomic claim under a Lease, outcome reporting with lease fencing, heartbeat with a cooperative-cancel channel, lease-expiry sweeps, operator actions that each write an atomic audit record, scheduling and mint, monitor reads, retention purges, and the observer-delivery machinery. Each method carries correctness rules the adapter must honor:

  • No double-claim under concurrency, and lease fencing by (WorkerId, Attempt) so a late report from a recovered worker returns a stale-lease result and changes nothing.
  • Failure detail is truncated, never rejected; a successful output that is too large is rejected loudly with JobOutputTooLargeException, never silently truncated.
  • Expired leases are disposed exactly once even under concurrent sweeps, and mint decisions are skipped whole on a stale cursor so a scheduled tick is never minted twice across the cluster.

A store also advertises two capability signals about itself. SupportsTransactionalEnqueue is a bool; when it is false, the store must loudly reject a non-null DbTransaction rather than silently ignore it. HistoryPolicy is a JobHistoryPolicy (default TransitionsAndFailureDetail) and is the single source of truth the monitor reads, so "history disabled" can never disagree with the store. Batched outcome reporting ships a default interface implementation that loops over the single-outcome path, so every adapter is correct without overriding it; an adapter may override it to batch in one fenced round-trip as long as it preserves the per-row fence verbatim.

A custom store is registered on the hosting builder. AddBackWave throws if none was configured.

Program.cs
services.AddBackWave(bw => bw
    // Any IJobStore implementation, resolved from the container.
    .UseStore(sp => new PostgresJobStore(sp.GetRequiredService<IConfiguration>()["Db"]!))
    // ...jobs, worker groups, etc.
);

Seams 2 & 3: optional store capabilities#

Two capabilities layer on top of IJobStore and are discovered at runtime by feature-testing the store object with a type check — store is IStoreFaultClassifier, and IWakeUpHintSource the same way. A store that does not implement them simply loses the capability, with a graceful fallback. Keeping them behind optional interfaces lets the host stay provider-agnostic while a capable adapter opts in.

IStoreFaultClassifier.IsTransientFault(Exception) lets an adapter classify provider-specific exceptions the generic DbException.IsTransient flag does not already cover. Most networked providers surface transient conditions (connection reset, failover blip, deadlock victim, command timeout) with IsTransient already set, so most adapters need not implement it. When you do, the implementation must be a pure, side-effect-free inspection: return true only for a recognized transient fault, so the worker degrades and retries on the next tick rather than fail-stopping; return false for everything else, including invariant violations and unknown faults, to defer to the host default. A false positive turns a fatal error into an endless retry, so it must never mistake a permanent fault for a transient one.

IWakeUpHintSource is the optional Wake-Up Hint channel. SubscribeAsync(Action<string> onHint, CancellationToken) subscribes and returns a Task<IAsyncDisposable> whose disposal tears the subscription down; the callback receives a Queue name and may be called concurrently, more than once for the same wake-up, or not at all. A hint only ever makes a worker poll a queue sooner. It has no delivery guarantee (hints may be dropped, duplicated, delayed, or reordered), and no correctness decision may depend on one. A store with no notification primitive simply does not implement it, and latency degrades to the configured poll interval. The subscription is self-healing: on channel loss it keeps reconnecting until disposed.

Seam 4: dashboard extensions#

IDashboardExtension is the contribution point through which a separately-installed package (in practice a BackWave.Pro.* Dashboard package) adds surfaces to the free Dashboard without the free Dashboard knowing about it. The Dashboard resolves every registered extension at render time and folds in what each returns; with none registered it renders exactly as it does on its own. The boundary is the package reference, never a license flag: a surface appears exactly when its extension is registered.

An extension supplies only the route shape, the component, the data loader, and the action handler. The free Dashboard keeps every security-critical concern for itself, including the same view-permission gate, antiforgery validation, live-refresh (SSE) path, and redirects that its built-in surfaces use. Because the extension is resolved from the container, it can take constructor dependencies to drive what it shows.

It contributes through four methods, each with a default-empty implementation so you override only what you need.

MethodContributes
NavEntries()DashboardNavEntry items, shown after built-in entries in registration order
Banner(basePath)A verbatim HTML fragment above page content; not sanitized, so it must be well-formed self-contained HTML
PageRoutes()DashboardPageRoute GET pages, rendered with the Dashboard's own layout
ActionRoutes()DashboardActionRoute POST actions, state-changing under post-redirect-get

A DashboardPageRoute names a Template (relative to the mount, with {name} capturing a segment), a Component (a Razor IComponent type), a Live flag (when true, the page refreshes in place over SSE), and a LoadAsync that returns render parameters given a DashboardPageContext, or null to render the Not Found page. On a live page, LoadAsync runs again each refresh tick. That context carries the HttpContext (used to read query args and resolve services such as BackWaveMonitor out of RequestServices), the captured route values, the resolved write affordances and sensitive-data permission, the dashboard options, and the mount base path. A DashboardActionRoute adds a Permission selector that chooses which default-deny permission guards the action (the Dashboard returns 403 when it denies, and requires antiforgery regardless), and a HandleAsync that does the work and returns a redirect path.

Program.cs
services.AddBackWaveDashboardExtension<MyDashboardExtension>();
 
public sealed class MyDashboardExtension : IDashboardExtension
{
    public IEnumerable<DashboardNavEntry> NavEntries() =>
        new[] { new DashboardNavEntry("metrics", "Metrics", "/metrics") { After = "failures" } };
 
    public IEnumerable<DashboardPageRoute> PageRoutes() =>
        new[]
        {
            new DashboardPageRoute
            {
                Template = "metrics",
                Component = typeof(MetricsPage),
                Live = true,
                LoadAsync = ctx => LoadMetricsAsync(ctx),
            },
        };
}

The Workflow surface is the first real Dashboard Extension: it lives in the Pro Dashboard package, which is why the free Dashboard ships with no Workflow UI.

Seam 5: egress transition observers#

ITransitionObserver is host-supplied, egress-only code BackWave invokes when a job reaches a state you subscribe to, the sanctioned "do X when Y happens to a job" without polling. Its single method is OnTransitionAsync(ObserverContext context, CancellationToken cancellationToken). An observer only reacts: it observes Transitions (the Core's recorded outputs), never Events (the Core's inputs), and it can never veto, redirect, or rewrite a transition. It is the push twin of the Monitor's pull. The Transition Observer Internals page covers the delivery subsystem in depth; here is the seam itself and its obligations.

Delivery is at-least-once, driven by a Lease-claimed walk of the Transition Log. One node delivers each transition in the happy path, and a crash mid-delivery redelivers. It is deliberately not Effect-Once, since the fire is a new side effect outside the (workerId, attempt) fence, so the reaction must be idempotent, the same contract a job handler already carries. The callback runs at handler trust and is bounded by a per-delivery timeout; a throw, timeout, or hang is contained, never stops the worker that processes jobs, and simply marks the delivery for retry. The token is cancelled on timeout or shutdown, so honor it.

PaymentDeadLetterAlert.cs
public sealed class PaymentDeadLetterAlert : ITransitionObserver
{
    public async ValueTask OnTransitionAsync(ObserverContext context, CancellationToken cancellationToken)
    {
        // At-least-once: guard on an idempotency key before causing a side effect.
        var payload = await context.Payload.GetAsync(cancellationToken); // lazy: only read if needed
        // ...post to your alerting sink, honoring cancellationToken...
    }
}

ObserverContext carries the transition facts eagerly (JobId, WireName, Queue, State, Attempt, Timestamp, and capture-gated FailureDetail), plus a lazy Payload accessor read only on first touch, which reports the job as unavailable when retention has already purged it. An ObserverSubscription declares what an observer cares about: a required list of JobState values, optionally narrowed by WireName and Queue (null matches all). ObserverSubscription.AllTransitions is a ready-made "audit everything" subscription you narrow with a with expression.

Registration flows through the hosting builder. The id you give each observer is a stable identifier that keys the durable delivery cursor: reuse resumes delivery where it left off, and changing it starts a fresh cursor. Registering the same id twice throws. Each observer is resolved fresh from a DI scope per delivery, so it may take scoped dependencies such as a DbContext for its dedup write. This is the idempotent-subscriber pattern.

Program.cs
bw.AddObservers(obs => obs
    .Add<AuditObserver>("audit", ObserverSubscription.AllTransitions)
    .Add<PaymentDeadLetterAlert>(
        "payment-dead-letter",
        new ObserverSubscription(new[] { JobState.DeadLettered }) { WireName = "PaymentJob" }));

Observers require a Job History Policy of at least Transitions: with history Off no rows are recorded, so nothing can be observed. That is checked at container composition (registering an observer while the policy is Off throws), so the misconfiguration fails fast rather than an observer silently never firing. A single background dispatcher per process drives every registered observer and costs nothing when none are registered.

PropertyBehavior
Delivery guaranteeAt-least-once; the same transition may arrive more than once
IdempotencyThe subscriber's responsibility, the same contract a job handler carries
Failure containmentA throw, timeout, or hang is contained; the delivery is marked for retry
Scope of powerEgress-only: observes Transitions, never Events; cannot veto, redirect, or rewrite
PrerequisiteJob History Policy ≥ Transitions, checked at container composition
Cursor / idA stable id keys the durable cursor; reuse resumes, change starts fresh
Cost when unusedZero; no pump polls and the dashboard surface is empty

Why there is no filter or middleware pipeline#

A common Hangfire migration question is "where are my job filters?" BackWave deliberately does not port IServerFilter / IElectStateFilter and the attributes that wrap execution. User code may observe the lifecycle; it may never intercept it. There are three shapes a "hook" could take, and each lands somewhere different: a lifecycle observer is built and sanctioned (the ITransitionObserver seam above); a handler-execution filter that wraps invocation is not built; and a state-election interceptor that vetoes or rewrites a transition is permanently rejected, because it would put user code inside the Core's sole authority and pull non-deterministic, production-only behavior into the decision path, breaking both the determinism boundary and the simulation story.

The handler-execution filter is not needed because most of what filters do is already first-class.

What a filter / attribute is used forWhere it lands in BackWave
AutomaticRetryCore retries, first-class
DisableConcurrentExecutionConcurrency limit / no-overlap, first-class
Logging / metrics around executionSubscribe a listener or OTel exporter to the BackWave ActivitySource / Meter
Capture an exception and ship it somewhereAn egress observer on the failure transition
IElectStateFilter (alter the outcome or state)Rejected; Core authority, breaks the determinism boundary
Per-job ambient setup (scoped DbContext, tenant / correlation scope)Per-Attempt DI scope, or a IJobHandler<T> decorator

The one genuine residue, wrapping handler invocation with ambient setup and teardown, splits into two needs, each with a smaller home than a filter pipeline. Scoped dependencies per execution are handled by giving each Attempt its own DI scope at the handler-invocation seam; handlers already register scoped and are resolved per attempt, so this is a Shell-only concern, not a user extension point. A cross-cutting wrap of every handler (say, opening a tenant scope out of payload metadata) is served by the idiomatic .NET decorator: an IJobHandler<T> that wraps IJobHandler<T>, registered in DI, needing zero new BackWave surface.

TenantScopeHandler.cs
// The sanctioned "wrap every handler" path: a decorator over IJobHandler<T>.
public sealed class TenantScopeHandler<T> : IJobHandler<T>
{
    private readonly IJobHandler<T> _inner;
    public TenantScopeHandler(IJobHandler<T> inner) => _inner = inner;
 
    public async Task HandleAsync(T job, /* context */ CancellationToken ct)
    {
        using var _ = OpenTenantScope(job); // ambient setup
        await _inner.HandleAsync(job, ct);  // then the real handler
    }
}

The guardrail is simple. The supported way to react to the lifecycle is the egress observer; the supported way to wrap execution is a DI decorator plus the per-attempt scope; and neither can alter a Core decision. Any future proposal to let a reaction change behavior (skip an execution, rewrite a target state, swap an outcome) is the rejected interceptor, and it reopens that decision explicitly rather than sneaking in as an observer feature.

Where to go next#