Skip to content
Docs

Extending BifrostQL — Hooks & Providers

BifrostQL generates the whole GraphQL API from your schema, but real apps need their own rules: a filter the metadata can’t express, a mutation that must be vetoed under a business condition, an async call to an external service before a write commits. Every one of these is a small, typed C# class that you register with one line.

This guide covers the programming surface. For the zero-code, metadata-driven modules (tenant isolation, soft-delete, audit columns), see the Module System.

Every extension point has a generic Add…<T> overload on the AddBifrostQL options. The type is resolved from DI, so your class can take constructor dependencies:

using BifrostQL.Server;
builder.Services.AddBifrostQL(o => o
.BindStandardConfig(builder.Configuration)
.AddFilterTransformer<RegionFilter>()
.AddMutationTransformer<InvoiceLockTransformer>()
.AddQueryObserver<SlowQueryLogger>());
MethodRegistersInterface
AddFilterTransformer<T>()A WHERE-clause injectorIFilterTransformer
AddMutationTransformer<T>()A mutation rewriterIMutationTransformer
AddQueryObserver<T>()A lifecycle side-effectIQueryObserver

Need to register a collection built at runtime? Non-generic overloads take instances or a factory:

o.AddFilterTransformers(sp => BuildTenantFilters(sp)); // Func<IServiceProvider, IReadOnlyList<IFilterTransformer>>
o.AddMutationTransformers(new[] { new AuditMutationTransformer() });

A filter transformer injects a WHERE clause on every read of a table. Extend SingleColumnFilterTransformerBase for the common single-column case:

public sealed class RegionFilter : SingleColumnFilterTransformerBase
{
// metadata key it reacts to, and a priority (lower = applied closer to the query)
public RegionFilter() : base("region-filter", priority: 150) { }
protected override TableFilter BuildFilter(IDbTable table, string column, IBifrostContext ctx)
=> TableFilter.Equals(column, ctx.UserContext["region"]);
}

Throwing from a filter transformer aborts the query — use that to fail closed on missing security context.

Priority ranges decide nesting order:

  • 0–99 — security and tenant isolation (applied innermost)
  • 100–199 — data filtering (soft-delete, region)
  • 200+ — application filters

A mutation transformer can rewrite or block a mutation before SQL is generated. The pipeline is async — implement TransformAsync:

public sealed class InvoiceLockTransformer : MetadataMutationTransformerBase
{
public InvoiceLockTransformer() : base("invoice-lock", priority: 200) { }
protected override async ValueTask<MutationTransformResult> TransformCoreAsync(
IDbTable table,
MutationType mutationType,
Dictionary<string, object?> data,
MutationTransformContext context)
{
if (mutationType == MutationType.Update && await IsPostedAsync(data, context))
return MutationTransformResult.Reject("Posted invoices cannot be edited.");
return MutationTransformResult.Unchanged(data);
}
}

A transformer can change the operation type — that is exactly how soft-delete turns a DELETE into an UPDATE.

Filter and mutation transformers run before SQL executes. A before-commit hook runs inside the open transaction, after the rows are staged but before the commit — the last place to enforce an invariant that depends on the database state the mutation just produced.

public sealed class CreditLimitHook : IBeforeCommitMutationHook
{
public async ValueTask<IReadOnlyList<string>> BeforeCommitAsync(MutationObserverContext context)
{
var balance = await context.ScalarAsync<decimal>(
"SELECT SUM(amount) FROM orders WHERE customer_id = @id",
("@id", context.Row["customer_id"]));
return balance > 10_000m
? new[] { "Order would exceed the customer credit limit." }
: Array.Empty<string>(); // empty = allow the commit
}
}

Return a non-empty list (or throw) and the whole transaction rolls back; an empty list lets the commit proceed. Register the hook in DI:

builder.Services.AddScoped<IBeforeCommitMutationHook, CreditLimitHook>();

This is the right tool for cross-row business rules — “no two active managers per team”, “stock must stay non-negative” — that can only be checked once the write is applied.

For field- and row-level validation that may need I/O (uniqueness checks, an external policy service), implement IServerValidationProvider and point a table at it with the validation-plugin metadata key:

public sealed class ContactRules : IServerValidationProvider
{
public string Name => "custom-contact-rules";
public async ValueTask<IReadOnlyList<string>> ValidateAsync(
ServerValidationContext context,
CancellationToken ct = default)
{
if (await EmailExistsAsync(context.Values["email"], ct))
return new[] { "Email is already registered." };
return Array.Empty<string>();
}
}
dbo.contacts { validation-plugin: custom-contact-rules }

The same rule set powers both server enforcement and client exposure — declarative rules (required, min, max, pattern, …) are derived once and surfaced to generated forms, so the browser and the server never drift. See Computed Columns & Validation.

Observers are pure side effects — logging, metrics, tracing. They fire at four phases and cannot modify the query; an observer that throws is logged and the query continues.

public sealed class SlowQueryLogger : IQueryObserver
{
public ValueTask OnAfterExecuteAsync(QueryObserverContext ctx)
{
if (ctx.Elapsed > TimeSpan.FromSeconds(1))
_log.LogWarning("Slow query on {Table}: {Ms}ms", ctx.Table, ctx.Elapsed.TotalMilliseconds);
return ValueTask.CompletedTask;
}
}
PhaseWhen
ParsedAfter the GraphQL request becomes the internal query tree
TransformedAfter all filter/mutation transformers have run
BeforeExecuteImmediately before SQL execution
AfterExecuteAfter SQL execution completes

When you write a parent row and its children in one mutation (tree-sync), every nested operation flows through the same transformer pipeline and the whole tree commits in one SQL-level transaction (BEGIN … COMMIT, rolled back on any failure). Tenant filters, soft-delete rewrites, audit columns, validators, and before-commit hooks all apply to children exactly as they do to top-level rows — there is no “back door” that skips your rules.

BifrostQL validates all metadata at model build time, not on the first request. A typo in a computed-sql expression, an auto-filter mapping that names a missing column, or a malformed state-machine transition aborts startup with a single error naming the schema, table, key, and offending value — so misconfiguration surfaces in CI, not in production traffic.