Skip to main content

Recipes you can ship.

Copy-paste patterns for real systems: auth, allowlists, multi-tenant filtering, observability, caching, and safe defaults.

Critical Pattern

Authorization First

The most important pattern: apply server-enforced scope BEFORE user queries.

Dynamic queries don't bypass authorization — sloppy endpoints do.

Mandatory Scope First
app.MapPost("/orders/search", 
    (ServiceQueryRequest request, AppDb db, HttpContext ctx) =>
{
    var userId = ctx.User.GetUserId();
    
    // 1. Server-enforced scope FIRST (mandatory)
    var baseQuery = db.Orders
        .Where(o => o.UserId == userId)
        .Where(o => !o.IsDeleted);
    
    // 2. User's filter applied INSIDE that scope
    return Results.Ok(request.Execute(baseQuery));
});

The user's query operates within your security boundary — not around it.

Field Allowlist Validation
var allowedFields = new HashSet<string> 
{ 
    "Name", "Status", "CreatedAt", "Category" 
};

// Validate before execution
var requestedFields = request.GetReferencedFields();
var disallowed = requestedFields.Except(allowedFields);

if (disallowed.Any())
{
    return Results.BadRequest(
        $"Fields not queryable: {string.Join(", ", disallowed)}");
}

return Results.Ok(request.Execute(queryable));

Only expose fields you intend to. Block everything else.

Security Pattern

Field Allowlists

Define exactly which fields are queryable, sortable, and aggregatable. If clients can query fields you didn't intend to expose, they'll find them.

Every queryable field is an attack surface — expose only what you intend.

Protection Pattern

Complexity Limits

Cap filter depth, sort count, and page size. If you expose dynamic queries without limits, you've built an accidental "export everything" API.

No limits = unlimited exports. Set page size caps before you ship.

Enforce Limits
if (request.GetPageSize() > 100)
{
    return Results.BadRequest("Page size cannot exceed 100");
}
else
{
    var result = request.Execute(queryable, options);
}

Server-enforced caps that clients cannot override.

Safe Query Logging
app.MapPost("/products/search", 
    (ServiceQueryRequest request, AppDb db, ILogger logger) =>
{
    // Log normalized query structure, NOT raw user input
    logger.LogInformation(
        "Query: Fields={Fields}, Filters={FilterCount}, PageSize={PageSize}",
        request.GetReferencedFields(),
        request.GetFilterCount(),
        request.GetPageSize());
    
    var stopwatch = Stopwatch.StartNew();
    var result = request.Execute(db.Products);
    stopwatch.Stop();
    
    // Metric for slow queries
    if (stopwatch.ElapsedMilliseconds > 500)
    {
        logger.LogWarning("Slow query: {ElapsedMs}ms", 
            stopwatch.ElapsedMilliseconds);
    }
    
    return Results.Ok(result);
});
Operations Pattern

Observability

Log the normalized query structure — operators, fields, limits — not raw user input.

You can't secure what you can't see — log structure, not raw input.

Isolation Pattern

Multi-Tenant Isolation

Tenant scope is non-negotiable. The user's query operates within the tenant boundary.

Cross-tenant data leaks end companies — scope first, always.

Tenant Isolation
app.MapPost("/api/{tenantId}/items/search", 
    (string tenantId, ServiceQueryRequest request, AppDb db) =>
{
    // Tenant scope is non-negotiable
    var baseQuery = db.Items
        .Where(i => i.TenantId == tenantId);
    
    // User query operates within tenant boundary
    return Results.Ok(request.Execute(baseQuery));
});

More Patterns

Additional recipes for common scenarios.

Combining Server + Client Filters
// Server-defined base filters (always applied)
var serverFilters = new ServiceQueryRequestBuilder()
    .IsEqual("IsActive", "true")
    .IsNull("DeletedAt")
    .Build();

// Merge with client request
var combined = serverFilters.Merge(clientRequest);

return Results.Ok(combined.Execute(queryable));

Preset filters that always apply, merged with user's query.

Role-Based Field Access
var fieldsForRole = new Dictionary<string, string[]>
{
    ["Admin"] = new[] { "Id", "Name", "Email", "Salary", "SSN" },
    ["Manager"] = new[] { "Id", "Name", "Email", "Salary" },
    ["User"] = new[] { "Id", "Name", "Email" }
};

var allowed = fieldsForRole[user.Role];
var requested = request.GetReferencedFields();

if (requested.Except(allowed).Any())
    return Results.Forbid();

Different roles see different fields.

Expression Caching
// Cache compiled expressions for performance
var cacheKey = request.GetCacheKey();

if (!_cache.TryGetValue(cacheKey, out var compiled))
{
    compiled = request.Compile<Product>();
    _cache.Set(cacheKey, compiled, TimeSpan.FromMinutes(5));
}

return Results.Ok(compiled.Execute(queryable));

Avoid recompiling the same expression repeatedly.

Timeout Protection
app.MapPost("/search", async (
    ServiceQueryRequest request, 
    AppDb db, 
    CancellationToken ct) =>
{
    using var cts = CancellationTokenSource
        .CreateLinkedTokenSource(ct);
    cts.CancelAfter(TimeSpan.FromSeconds(30));
    
    var result = await request.ExecuteAsync(
        db.Items, cts.Token);
    
    return Results.Ok(result);
});

Kill queries that run too long.

Ready to build?

Get started in 10 minutes or review the security documentation.