DataSurface

December 30, 2025 Β· View on GitHub

Contract-driven CRUD HTTP endpoints for ASP.NET Core

DataSurface eliminates CRUD boilerplate by generating fully-featured HTTP endpoints from a single source of truth: the ResourceContract. Define your resources once using C# attributes or database metadata, and get automatic validation, filtering, sorting, pagination, and more.

Publish NuGet

You define what a resource is β€” fields, validation, security, relations β€” and DataSurface handles:

  • CRUD endpoints
  • Validation
  • Filtering, sorting, pagination
  • Authorization & row-level security
  • Concurrency, caching, auditing, and observability

All without writing DTOs, controllers, or repetitive glue code.

🚫 What DataSurface Removes

  • Handwritten CRUD controllers
  • Read/Create/Update/Delete DTOs
  • Manual validation plumbing
  • Query parsing logic
  • Boilerplate authorization checks
  • Repeated Swagger/OpenAPI definitions

βœ… What You Keep

  • Full control over your domain model
  • Strong typing
  • Explicit security rules
  • Override hooks when you do need custom logic

Why DataSurface?

Most ASP.NET Core applications repeat the same pattern:

  • Entity
  • DTOs (Read / Create / Update)
  • Controller
  • Validation
  • Query parsing
  • Authorization checks

Multiply that by 20–50 entities and the cost becomes significant.

DataSurface collapses all of that into one contract.

You describe what is allowed, not how to wire it.

The result:

  • Fewer files
  • Less drift between layers
  • Consistent behavior across all resources
  • Faster iteration without sacrificing control

Before vs After

❌ Traditional CRUD

  • Entity
  • 3–5 DTOs
  • Controller with ~200 lines
  • Manual validation
  • Manual filtering & paging
  • Swagger configuration
  • Repeated authorization logic
User.cs
UserReadDto.cs
UserCreateDto.cs
UserUpdateDto.cs
UsersController.cs
UserValidator.cs

βœ… With DataSurface

  • Entity
  • Attributes describing the contract
[CrudResource("users")]
public class User
{
    [CrudKey]
    public int Id { get; set; }

    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update, RequiredOnCreate = true)]
    public string Email { get; set; } = default!;
}
app.MapDataSurfaceCrud();

That’s it!

Usage Modes

DataSurface can be used in two ways:

🌐 HTTP API (Most Common)

  • Generates REST endpoints via Minimal APIs
  • Full OpenAPI / Swagger support
  • Ideal for frontend, mobile, or external integrations
GET    /api/users
POST   /api/users
PATCH  /api/users/{id}
DELETE /api/users/{id}

βš™οΈ In-Process (No HTTP)

  • Call CRUD operations directly
  • Same validation, security, hooks, and contracts
  • Ideal for internal services, background jobs, or modular monoliths
await crudService.CreateAsync("User", body, context, ct);

No controllers. No HTTP. Same guarantees.

When to Use DataSurface

βœ… You build data-heavy APIs
βœ… You want consistent CRUD behavior
βœ… You want fewer DTOs and controllers
βœ… You need strong validation & security
βœ… You support dynamic or metadata-driven entities

When NOT to Use DataSurface

❌ You want full handcrafted controllers for every endpoint
❌ Your API is mostly bespoke workflows, not CRUD
❌ You dislike declarative configuration

DataSurface is not a replacement for custom business logic β€” it handles the 80% so you can focus on the 20%.

Table of Contents

Features

FeatureDescription
Auto-generated endpointsGET, POST, PATCH, DELETE, PUT via Minimal APIs
Field-level controlChoose which fields appear in read/create/update DTOs
Default valuesAutomatically apply defaults when creating resources
Computed fieldsServer-calculated read-only fields
ValidationRequired, immutable, length, range, regex, allowed values
Field projectionSelect specific fields via ?fields= query parameter
Soft deleteBuilt-in ISoftDelete convention support
TimestampsAuto-populate CreatedAt/UpdatedAt via ITimestamped
Filtering & SortingAllowlisted fields with operators (eq, gt, contains, etc.)
PaginationBuilt-in page + pageSize with configurable max
Expansionexpand=relation with depth limits
HEAD supportHEAD requests return count headers without body
AuthorizationPer-operation policy names
Row-level securityIResourceFilter<T> for tenant/user-based query filtering
Resource authorizationIResourceAuthorizer<T> for instance-level access control
Field authorizationIFieldAuthorizer for field-level read/write control
Tenant isolationAutomatic multi-tenancy with [CrudTenant] attribute
ConcurrencyRow version + ETag / If-Match headers
HooksGlobal and entity-specific lifecycle hooks
OverridesReplace any CRUD operation with custom logic
Dynamic entitiesRuntime-defined resources without recompilation
Compiled queriesPre-compiled EF Core queries for common operations
Query cachingOptional IDistributedCache integration
Response cachingETag-based 304 responses, configurable Cache-Control
Bulk operationsBatch create/update/delete via /bulk endpoint
Import/ExportBulk data import/export in JSON or CSV format
Async streamingIAsyncEnumerable support via /stream endpoint
WebhooksPublish events when CRUD operations occur
Rate limitingASP.NET Core rate limiting integration
API key authenticationMachine-to-machine authentication
Audit loggingIAuditLogger for tracking all CRUD operations
Structured loggingBuilt-in ILogger integration with operation timing
MetricsOpenTelemetry-compatible counters and histograms
Distributed tracingActivity/span integration for request tracing
Health checksIHealthCheck implementations for monitoring
Schema endpointGET /api/$schema/{resource} returns JSON Schema
Feature flagsSelectively enable/disable features with presets

Packages

PackagePurposeDownload
DataSurface.CoreContracts, attributes, and buildersNuGet Downloads
DataSurface.EFCoreEF Core CRUD service, hooks, query engineNuGet Downloads
DataSurface.DynamicRuntime metadata storage, dynamic CRUD serviceNuGet Downloads
DataSurface.HttpMinimal API endpoint mapping, query parsing, ETagsNuGet Downloads
DataSurface.AdminAdmin endpoints for managing dynamic entitiesNuGet Downloads
DataSurface.OpenApiSwashbuckle integration for typed schemasNuGet Downloads
DataSurface.Generator(Optional) Source generator for typed DTOsNuGet Downloads

Typical combinations:

  • Static only: Core + EFCore + Http
  • Dynamic only: Core + Dynamic + Http + Admin
  • Both: All of the above

Quick Start

1. Define your entity

using DataSurface.Core.Annotations;
using DataSurface.Core.Enums;

[CrudResource("users")]
public class User
{
    [CrudKey]
    public int Id { get; set; }

    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update, RequiredOnCreate = true)]
    public string Email { get; set; } = default!;

    [CrudField(CrudDto.Read | CrudDto.Filter | CrudDto.Sort)]
    public DateTime CreatedAt { get; set; }

    [CrudConcurrency]
    public byte[] RowVersion { get; set; } = default!;
}

2. Register services

using DataSurface.EFCore.Services;
using System.Reflection;

// Register contracts and EF Core services
builder.Services.AddDataSurfaceEfCore(opt =>
{
    opt.AssembliesToScan = [Assembly.GetExecutingAssembly()];
});

// Register CRUD runtime
builder.Services.AddScoped<CrudHookDispatcher>();
builder.Services.AddSingleton<CrudOverrideRegistry>();
builder.Services.AddScoped<EfDataSurfaceCrudService>();
builder.Services.AddScoped<IDataSurfaceCrudService>(sp => 
    sp.GetRequiredService<EfDataSurfaceCrudService>());

3. Map endpoints

using DataSurface.Http;

app.MapDataSurfaceCrud();

Result: Your API now has these endpoints:

  • GET /api/users β€” List with filtering, sorting, pagination
  • HEAD /api/users β€” Get count only (in X-Total-Count header)
  • GET /api/users/{id} β€” Get single resource
  • POST /api/users β€” Create
  • PATCH /api/users/{id} β€” Update
  • DELETE /api/users/{id} β€” Delete
  • GET /api/$schema/users β€” Get JSON Schema for resource

Guides

Guide: Static Resources (EF Core)

For compile-time defined entities backed by Entity Framework Core.

Step 1: Annotate your entities

[CrudResource("posts", MaxPageSize = 100)]
public class Post
{
    [CrudKey]
    public int Id { get; set; }

    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update | CrudDto.Filter | CrudDto.Sort,
        RequiredOnCreate = true, MaxLength = 200)]
    public string Title { get; set; } = default!;

    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update)]
    public string? Content { get; set; }

    [CrudField(CrudDto.Read | CrudDto.Filter)]
    public int AuthorId { get; set; }

    [CrudField(CrudDto.Read)]
    public DateTime CreatedAt { get; set; }

    [CrudRelation(ReadExpandAllowed = true, WriteMode = RelationWriteMode.ById)]
    public User Author { get; set; } = default!;

    [CrudConcurrency]
    public byte[] RowVersion { get; set; } = default!;
}

Step 2: Create your DbContext

using DataSurface.EFCore.Context;

public class AppDbContext : DeclarativeDbContext<AppDbContext>
{
    public AppDbContext(
        DbContextOptions<AppDbContext> options,
        DataSurfaceEfCoreOptions dsOptions,
        IResourceContractProvider contracts)
        : base(options, dsOptions, contracts) { }
}

Step 3: Register all services

// Program.cs
using DataSurface.EFCore.Services;
using DataSurface.Http;

var builder = WebApplication.CreateBuilder(args);

// EF Core
builder.Services.AddDbContext<AppDbContext>(opt => 
    opt.UseSqlServer(connectionString));

// DataSurface contracts
builder.Services.AddDataSurfaceEfCore(opt =>
{
    opt.AssembliesToScan = [typeof(Program).Assembly];
});

// DataSurface runtime
builder.Services.AddScoped<CrudHookDispatcher>();
builder.Services.AddSingleton<CrudOverrideRegistry>();
builder.Services.AddScoped<EfDataSurfaceCrudService>();
builder.Services.AddScoped<IDataSurfaceCrudService>(sp => 
    sp.GetRequiredService<EfDataSurfaceCrudService>());

var app = builder.Build();

// Map CRUD endpoints
app.MapDataSurfaceCrud();

app.Run();

Guide: Dynamic Resources (Runtime Metadata)

For entities defined at runtime without recompilation.

Step 1: Add dynamic tables to your DbContext

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);
    modelBuilder.AddDataSurfaceDynamic(schema: "dbo");
}

Step 2: Register dynamic services

using DataSurface.Dynamic.DI;
using DataSurface.Dynamic.Contracts;

// Static contracts (if any)
builder.Services.AddDataSurfaceEfCore(opt => { /* ... */ });

// Dynamic contracts
builder.Services.AddDataSurfaceDynamic(opt =>
{
    opt.Schema = "dbo";
    opt.WarmUpContractsOnStart = true;
});

// Use composite provider for both static + dynamic
builder.Services.AddScoped<IResourceContractProvider>(sp => 
    sp.GetRequiredService<CompositeResourceContractProvider>());

// Use router to dispatch to correct backend
builder.Services.AddScoped<DataSurfaceCrudRouter>();
builder.Services.AddScoped<IDataSurfaceCrudService>(sp => 
    sp.GetRequiredService<DataSurfaceCrudRouter>());

Step 3: Map with dynamic catch-all enabled

app.MapDataSurfaceCrud(new DataSurfaceHttpOptions
{
    MapStaticResources = true,
    MapDynamicCatchAll = true  // Enables /api/d/{route}
});

Guide: Admin Endpoints

Manage dynamic entity definitions via REST API.

using DataSurface.Admin.DI;
using DataSurface.Admin;

builder.Services.AddDataSurfaceAdmin();

app.MapDataSurfaceAdmin(new DataSurfaceAdminOptions
{
    Prefix = "/admin/ds",
    RequireAuthorization = true,
    Policy = "DataSurfaceAdmin"
});

Available endpoints:

MethodPathDescription
GET/admin/ds/entitiesList all entity definitions
GET/admin/ds/entities/{key}Get single entity definition
PUT/admin/ds/entities/{key}Create or update entity definition
DELETE/admin/ds/entities/{key}Delete entity definition
GET/admin/ds/exportExport all definitions as JSON
POST/admin/ds/importImport definitions from JSON
POST/admin/ds/entities/{key}/reindexRebuild search indexes

Guide: OpenAPI / Swagger

Generate typed schemas for Swashbuckle.

using DataSurface.OpenApi;

builder.Services.AddSwaggerGen(swagger =>
{
    builder.Services.AddDataSurfaceOpenApi(swagger);
});

This adds:

  • Typed request/response schemas per resource
  • Query parameter documentation for filtering
  • Proper PagedResult<T> schema for list responses

Auto-generated Endpoints

DataSurface generates fully-featured REST endpoints via Minimal APIs:

MethodEndpointDescription
GET/api/{resource}List with filtering, sorting, pagination
HEAD/api/{resource}Get count only (in X-Total-Count header)
GET/api/{resource}/{id}Get single resource
POST/api/{resource}Create new resource
PATCH/api/{resource}/{id}Partial update (only provided fields)
PUT/api/{resource}/{id}Full replacement (all fields required)
DELETE/api/{resource}/{id}Delete resource
GET/api/$schema/{resource}Get JSON Schema for resource

PUT vs PATCH:

  • PATCH β€” Partial update: only fields in the request body are modified
  • PUT β€” Full replacement: all updatable fields must be provided (returns 400 if any are missing)

To enable PUT endpoints:

app.MapDataSurfaceCrud(new DataSurfaceHttpOptions
{
    EnablePutForFullUpdate = true
});

See Quick Start for setup instructions.


Field-level Control

Control which fields appear in read/create/update DTOs using the [CrudField] attribute with CrudDto flags.

[CrudResource("products")]
public class Product
{
    [CrudKey]
    public int Id { get; set; }

    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update, RequiredOnCreate = true)]
    public string Name { get; set; } = default!;

    [CrudField(CrudDto.Read | CrudDto.Create)]  // Can set on create, but not update
    public string SKU { get; set; } = default!;

    [CrudField(CrudDto.Read)]  // Read-only, never in request bodies
    public DateTime CreatedAt { get; set; }

    // No attribute = not exposed via API
    internal string InternalNotes { get; set; } = default!;
}

See [CrudField] in Attributes Reference for full details.


Default Values

Automatically apply default values when creating resources. Defaults are applied server-side when a field is not provided in the request body:

[CrudResource("orders")]
public class Order
{
    [CrudKey]
    public int Id { get; set; }

    [CrudField(CrudDto.Read | CrudDto.Create, DefaultValue = "pending")]
    public string Status { get; set; } = default!;

    [CrudField(CrudDto.Read | CrudDto.Create, DefaultValue = 0)]
    public int Priority { get; set; }

    [CrudField(CrudDto.Read | CrudDto.Create, DefaultValue = false)]
    public bool IsUrgent { get; set; }
}

Behavior:

  • Defaults are only applied on create operations (POST)
  • If a field is provided in the request, the provided value is used
  • If a field is omitted, the DefaultValue is applied
  • Works with strings, numbers, booleans, and other primitive types

Computed Fields

Define server-calculated read-only fields that are evaluated at read time. Computed fields are never stored in the databaseβ€”they're calculated dynamically based on other field values:

[CrudResource("employees")]
public class Employee
{
    [CrudKey]
    public int Id { get; set; }

    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update)]
    public string FirstName { get; set; } = default!;

    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update)]
    public string LastName { get; set; } = default!;

    [CrudField(CrudDto.Read, ComputedExpression = "FirstName + ' ' + LastName")]
    public string FullName { get; set; } = default!;

    [CrudField(CrudDto.Read | CrudDto.Create)]
    public decimal Salary { get; set; }

    [CrudField(CrudDto.Read | CrudDto.Create)]
    public decimal Bonus { get; set; }

    [CrudField(CrudDto.Read, ComputedExpression = "Salary + Bonus")]
    public decimal TotalCompensation { get; set; }
}

Supported Expressions:

  • String concatenation: "FirstName + ' ' + LastName"
  • Numeric operations: "Salary + Bonus", "Price * Quantity"
  • Property references: Direct property names like "PropertyName"

Notes:

  • Computed fields are read-onlyβ€”they cannot be set via POST or PATCH
  • Values are calculated fresh on every read operation
  • The expression references CLR property names (not API names)

Validation

DataSurface provides comprehensive built-in validation via [CrudField] attributes:

[CrudResource("users")]
public class User
{
    [CrudKey]
    public int Id { get; set; }

    // Required on create
    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update, RequiredOnCreate = true)]
    public string Email { get; set; } = default!;

    // Immutable after creation
    [CrudField(CrudDto.Read | CrudDto.Create, Immutable = true)]
    public string Username { get; set; } = default!;

    // String length validation
    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update, MinLength = 8, MaxLength = 100)]
    public string Password { get; set; } = default!;

    // Numeric range validation
    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update, Min = 0, Max = 150)]
    public int Age { get; set; }

    // Regex pattern validation
    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update, Regex = @"^\+?[1-9]\d{1,14}$")]
    public string? PhoneNumber { get; set; }

    // Allowed values (enum-like validation)
    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update, AllowedValues = "Active|Inactive|Pending")]
    public string Status { get; set; } = default!;
}

Validation Rules:

RuleDescription
RequiredOnCreateField must be present on POST requests
ImmutableField rejected on PATCH requests (can only be set on create)
MinLengthMinimum string length
MaxLengthMaximum string length
MinMinimum numeric value
MaxMaximum numeric value
RegexRegular expression pattern the value must match
AllowedValuesPipe-separated list of valid values

Additional Behavior:

  • Unknown fields in request bodies are automatically rejected
  • Validation errors return HTTP 400 with detailed problem details

Field Projection

Select specific fields to return using the ?fields= query parameter. This reduces payload size and improves performance:

GET /api/users?fields=id,email,name

Response:

{
  "items": [
    { "id": 1, "email": "john@example.com", "name": "John" },
    { "id": 2, "email": "jane@example.com", "name": "Jane" }
  ]
}

Usage:

  • Comma-separated list of field API names
  • Only requested fields are included in the response
  • Invalid field names are ignored
  • Works with list (GET /api/resource) and single (GET /api/resource/{id}) endpoints

Soft Delete

Entities implementing ISoftDelete are automatically filtered instead of permanently deleted:

using DataSurface.EFCore.Interfaces;

public class User : ISoftDelete
{
    public int Id { get; set; }
    public string Email { get; set; } = default!;
    
    // Automatically set to true on DELETE, filtered from queries
    public bool IsDeleted { get; set; }
}
  • On delete: IsDeleted is set to true instead of removing the row
  • On queries: Soft-deleted records are automatically filtered out
  • Control: Disable via EnableSoftDeleteFilter = false in options

Timestamps

Entities implementing ITimestamped get automatic timestamp population:

using DataSurface.EFCore.Interfaces;

public class User : ITimestamped
{
    public int Id { get; set; }
    public string Email { get; set; } = default!;
    
    // Auto-populated by DeclarativeDbContext
    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }
}
  • On insert: Both CreatedAt and UpdatedAt are set to DateTime.UtcNow
  • On update: Only UpdatedAt is refreshed
  • Control: Disable via EnableTimestampConvention = false in options

Filtering & Sorting

Filter operators

?filter[price]=100          # equals (default)
?filter[price]=eq:100       # equals
?filter[price]=neq:100      # not equals
?filter[price]=gt:100       # greater than
?filter[price]=gte:100      # greater than or equal
?filter[price]=lt:100       # less than
?filter[price]=lte:100      # less than or equal
?filter[name]=contains:john # string contains
?filter[name]=starts:john   # string starts with
?filter[name]=ends:son      # string ends with
?filter[status]=in:a|b|c    # in list (pipe-separated)
?filter[email]=isnull:true  # is null
?filter[email]=isnull:false # is not null

Search across all searchable fields using the q parameter:

?q=john                     # searches all fields marked with Searchable = true

Mark fields as searchable:

[CrudField(CrudDto.Read | CrudDto.Filter, Searchable = true)]
public string Title { get; set; }

[CrudField(CrudDto.Read | CrudDto.Filter, Searchable = true)]
public string Description { get; set; }

Field Projection

Return only specific fields using the fields parameter:

?fields=id,title,createdAt  # only return these fields

Sorting

?sort=title,-createdAt      # Comma-separated, `-` prefix for descending

Fields must have CrudDto.Filter or CrudDto.Sort flags to be filterable/sortable.


Pagination

ParameterExampleDescription
page?page=2Page number (1-based, default: 1)
pageSize?pageSize=50Items per page (default: 20)

Response format

{
  "items": [...],
  "page": 1,
  "pageSize": 20,
  "total": 142
}

Response headers (on list endpoints):

X-Total-Count: 142
X-Page: 1
X-Page-Size: 20

Maximum page size is configurable via MaxPageSize on [CrudResource].


Expansion

Include related resources using the expand parameter:

?expand=author,tags

Relations must have ReadExpandAllowed = true in [CrudRelation] to be expandable. Maximum expansion depth is configurable via MaxExpandDepth on [CrudResource].


HEAD Support

Use HEAD to get only the count without fetching data:

HEAD /api/users?filter[status]=active

Response:

HTTP/1.1 200 OK
X-Total-Count: 42
X-Page: 1
X-Page-Size: 200

Authorization

Set authorization policies per operation using [CrudAuthorize]:

[CrudAuthorize(Policy = "AdminOnly")]  // All operations
[CrudAuthorize(Operation = CrudOperation.Delete, Policy = "SuperAdmin")]
public class User { }

See [CrudAuthorize] in Attributes Reference for full details.


Row-level Security

Filter queries based on user context using IResourceFilter<T>:

using DataSurface.EFCore.Interfaces;

public class TenantResourceFilter : IResourceFilter<Order>
{
    private readonly ITenantContext _tenant;
    
    public TenantResourceFilter(ITenantContext tenant) => _tenant = tenant;
    
    public Expression<Func<Order, bool>>? GetFilter(ResourceContract contract)
        => o => o.TenantId == _tenant.TenantId;
}

// Register
builder.Services.AddScoped<IResourceFilter<Order>, TenantResourceFilter>();
  • Automatic application: Filters apply to List, Get, Update, and Delete operations
  • Security guarantee: Users can only access records matching the filter
  • Non-generic option: Implement IResourceFilter for dynamic type filtering

Resource Authorization

Authorize access to specific resource instances using IResourceAuthorizer<T>:

using DataSurface.EFCore.Interfaces;

public class OrderAuthorizer : IResourceAuthorizer<Order>
{
    private readonly IHttpContextAccessor _http;
    
    public OrderAuthorizer(IHttpContextAccessor http) => _http = http;
    
    public Task<AuthorizationResult> AuthorizeAsync(
        ResourceContract contract,
        Order? entity,
        CrudOperation operation,
        CancellationToken ct)
    {
        var userId = _http.HttpContext?.User.FindFirst("sub")?.Value;
        
        // Owner can do anything with their orders
        if (entity?.OwnerId == userId)
            return Task.FromResult(AuthorizationResult.Success());
        
        // Admins can access all orders
        if (_http.HttpContext?.User.IsInRole("Admin") == true)
            return Task.FromResult(AuthorizationResult.Success());
        
        return Task.FromResult(AuthorizationResult.Fail("You can only access your own orders."));
    }
}

// Register
builder.Services.AddScoped<IResourceAuthorizer<Order>, OrderAuthorizer>();

Integration with ASP.NET Core Authorization:

public class PolicyResourceAuthorizer : IResourceAuthorizer
{
    private readonly IAuthorizationService _auth;
    private readonly IHttpContextAccessor _http;
    
    public PolicyResourceAuthorizer(IAuthorizationService auth, IHttpContextAccessor http)
    {
        _auth = auth;
        _http = http;
    }
    
    public async Task<AuthorizationResult> AuthorizeAsync(
        ResourceContract contract,
        object? entity,
        CrudOperation operation,
        CancellationToken ct)
    {
        var user = _http.HttpContext?.User;
        if (user is null)
            return AuthorizationResult.Fail("No authenticated user.");
        
        // Use ASP.NET Core policy-based authorization with resource
        var policyName = $"{contract.ResourceKey}.{operation}";
        var result = await _auth.AuthorizeAsync(user, entity, policyName);
        
        return result.Succeeded 
            ? AuthorizationResult.Success() 
            : AuthorizationResult.Fail("Access denied by policy.");
    }
}

// Register
builder.Services.AddScoped<IResourceAuthorizer, PolicyResourceAuthorizer>();
  • Instance-level checks: "Can this user access Order #123?"
  • Operation-specific: Different rules for Get vs Update vs Delete
  • Typed and non-generic: Use IResourceAuthorizer<T> for compile-time safety or IResourceAuthorizer for global policies
  • Integrates with ASP.NET Core: Leverage existing IAuthorizationService and policies

Field Authorization

Control which fields users can read or write using IFieldAuthorizer:

using DataSurface.EFCore.Interfaces;

public class SensitiveFieldAuthorizer : IFieldAuthorizer
{
    private readonly IHttpContextAccessor _http;
    
    public SensitiveFieldAuthorizer(IHttpContextAccessor http) => _http = http;
    
    public bool CanReadField(ResourceContract contract, string fieldName)
    {
        if (fieldName == "salary")
            return _http.HttpContext?.User.IsInRole("HR") ?? false;
        return true;
    }
    
    public bool CanWriteField(ResourceContract contract, string fieldName, CrudOperation op)
    {
        if (fieldName == "isAdmin")
            return _http.HttpContext?.User.IsInRole("Admin") ?? false;
        return true;
    }
}

// Register
builder.Services.AddScoped<IFieldAuthorizer, SensitiveFieldAuthorizer>();
  • Read redaction: Unauthorized fields are removed from responses
  • Write validation: Unauthorized field writes throw UnauthorizedAccessException

Tenant Isolation

Implement automatic multi-tenancy with the [CrudTenant] attribute. Tenant isolation ensures users can only access data belonging to their tenant:

[CrudResource("orders")]
public class Order
{
    [CrudKey]
    public int Id { get; set; }

    [CrudTenant(ClaimType = "tenant_id", Required = true)]
    public string TenantId { get; set; } = default!;

    [CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Update)]
    public string ProductName { get; set; } = default!;

    [CrudField(CrudDto.Read | CrudDto.Create)]
    public decimal Amount { get; set; }
}

Behavior:

  • On queries: Automatically filters results to only include records matching the user's tenant claim
  • On create: Automatically sets the tenant field to the user's tenant claim value
  • On update/delete: Validates the resource belongs to the user's tenant

Configuration Options:

OptionDescription
ClaimTypeThe claim type to extract tenant ID from (e.g., "tenant_id", "org_id")
RequiredIf true, requests without the tenant claim are rejected with 401

Custom Tenant Resolution:

For advanced scenarios, implement ITenantResolver:

public class CustomTenantResolver : ITenantResolver
{
    private readonly IHttpContextAccessor _http;
    
    public CustomTenantResolver(IHttpContextAccessor http) => _http = http;
    
    public string? ResolveTenantId(TenantContract tenant)
    {
        // Custom logic: header, subdomain, database lookup, etc.
        return _http.HttpContext?.Request.Headers["X-Tenant-Id"].FirstOrDefault();
    }
}

// Register
builder.Services.AddScoped<ITenantResolver, CustomTenantResolver>();

Concurrency

Row version fields enable optimistic concurrency via ETag headers.

Response:

HTTP/1.1 200 OK
ETag: W/"AAAAAAB="

Update with concurrency check:

PATCH /api/users/1
If-Match: W/"AAAAAAB="
Content-Type: application/json

{"email": "new@example.com"}

See [CrudConcurrency] in Attributes Reference for configuration.


Hooks

Global Hooks

Run for all resources.

public class AuditHook : ICrudHook
{
    public int Order => 0;  // Lower runs first

    public Task BeforeAsync(CrudHookContext ctx)
    {
        Console.WriteLine($"Before {ctx.Operation} on {ctx.Contract.ResourceKey}");
        return Task.CompletedTask;
    }

    public Task AfterAsync(CrudHookContext ctx)
    {
        Console.WriteLine($"After {ctx.Operation}");
        return Task.CompletedTask;
    }
}

// Register
builder.Services.AddScoped<ICrudHook, AuditHook>();

Entity-Specific Hooks

Run only for a specific entity type.

public class UserHook : ICrudHook<User>
{
    public int Order => 0;

    public Task BeforeCreateAsync(User entity, JsonObject body, CrudHookContext ctx)
    {
        entity.CreatedAt = DateTime.UtcNow;
        return Task.CompletedTask;
    }

    public Task AfterCreateAsync(User entity, CrudHookContext ctx)
    {
        // Send welcome email
        return Task.CompletedTask;
    }
}

// Register
builder.Services.AddScoped<ICrudHook<User>, UserHook>();

Overrides

Completely replace CRUD logic for a resource.

var registry = app.Services.GetRequiredService<CrudOverrideRegistry>();

registry.Override("User", CrudOperation.Create, 
    async (CreateOverride)((contract, body, ctx, ct) =>
    {
        // Custom creation logic
        var user = new User { Email = body["email"]!.GetValue<string>() };
        ctx.Db.Add(user);
        await ctx.Db.SaveChangesAsync(ct);
        
        return new JsonObject { ["id"] = user.Id, ["email"] = user.Email };
    }));

Dynamic Entities

Runtime-defined resources without recompilation. See Guide: Dynamic Resources for full setup instructions.


Compiled Queries

Pre-compiled EF Core queries for common operations:

builder.Services.AddSingleton<CompiledQueryCache>();

// Usage in custom code
var cache = sp.GetRequiredService<CompiledQueryCache>();
var findById = cache.GetOrCreateFindByIdQuery<User, int>("Id");
var user = findById(dbContext, 5);

Query Caching

Cache query results using IDistributedCache:

// Add Redis cache
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost:6379";
});

// Configure DataSurface caching
builder.Services.Configure<DataSurfaceCacheOptions>(options =>
{
    options.EnableQueryCaching = true;
    options.DefaultCacheDuration = TimeSpan.FromMinutes(5);
    options.ResourceConfigs["Product"] = new ResourceCacheConfig
    {
        Duration = TimeSpan.FromMinutes(30),
        CacheList = true,
        CacheGet = true
    };
});

builder.Services.AddSingleton<IQueryResultCache, DistributedQueryResultCache>();

Response Caching

Enable ETag-based conditional GET (304 Not Modified) and Cache-Control headers:

app.MapDataSurfaceCrud(new DataSurfaceHttpOptions
{
    EnableConditionalGet = true,      // If-None-Match β†’ 304 response
    CacheControlMaxAgeSeconds = 300   // Cache-Control: max-age=300
});

Clients can cache responses and send If-None-Match headers to receive 304 responses when data hasn't changed.


Bulk Operations

Batch create, update, and delete operations via POST /api/{resource}/bulk:

{
  "create": [
    { "name": "User 1", "email": "user1@example.com" },
    { "name": "User 2", "email": "user2@example.com" }
  ],
  "update": [
    { "id": 5, "patch": { "name": "Updated Name" } }
  ],
  "delete": [10, 11, 12],
  "stopOnError": true,
  "useTransaction": true
}

Register the bulk service:

builder.Services.AddScoped<IDataSurfaceBulkService, EfDataSurfaceBulkService>();

Async Streaming

Stream large datasets via GET /api/{resource}/stream (NDJSON format):

// Register streaming service
builder.Services.AddScoped<IDataSurfaceStreamingService, EfDataSurfaceStreamingService>();

// Client usage
await foreach (var item in streamingService.StreamAsync("User", spec))
{
    // Process each item as it arrives
}

Response format (newline-delimited JSON):

{"id":1,"name":"User 1"}
{"id":2,"name":"User 2"}
{"id":3,"name":"User 3"}

Import/Export

Bulk data import and export via dedicated endpoints:

app.MapDataSurfaceCrud(new DataSurfaceHttpOptions
{
    EnableImportExport = true
});

Export Endpoint:

GET /api/users/export?format=json
GET /api/users/export?format=csv
  • Exports all records (respecting query filters and security)
  • Supports JSON and CSV formats
  • CSV format includes headers matching API field names

Import Endpoint:

POST /api/users/import
Content-Type: application/json

[
  { "email": "user1@example.com", "name": "User 1" },
  { "email": "user2@example.com", "name": "User 2" }
]
  • Imports an array of records
  • Each record is validated against the resource contract
  • Returns summary of imported, failed, and skipped records

Webhooks

Publish events when CRUD operations occur. Useful for integrations, audit trails, and event-driven architectures:

app.MapDataSurfaceCrud(new DataSurfaceHttpOptions
{
    EnableWebhooks = true
});

Implement and register a webhook publisher:

using DataSurface.Core.Webhooks;

public class MyWebhookPublisher : IWebhookPublisher
{
    private readonly HttpClient _http;
    private readonly ILogger<MyWebhookPublisher> _logger;
    
    public MyWebhookPublisher(HttpClient http, ILogger<MyWebhookPublisher> logger)
    {
        _http = http;
        _logger = logger;
    }
    
    public async Task PublishAsync(WebhookEvent evt, CancellationToken ct)
    {
        _logger.LogInformation("Webhook: {Operation} on {Resource} id={Id}", 
            evt.Operation, evt.ResourceKey, evt.EntityId);
        
        // Send to external endpoint
        await _http.PostAsJsonAsync("https://hooks.example.com/datasurface", evt, ct);
    }
}

// Register
builder.Services.AddSingleton<IWebhookPublisher, MyWebhookPublisher>();

WebhookEvent properties:

  • Operation β€” Create, Update, or Delete
  • ResourceKey β€” The resource that changed
  • EntityId β€” ID of the affected entity
  • Timestamp β€” UTC timestamp
  • Payload β€” JSON representation of the entity (for create/update)

Failure Handling:

  • Webhook publishing is fire-and-forget by default
  • Failures are logged but don't fail the CRUD operation
  • Implement retry logic in your IWebhookPublisher if needed

Rate Limiting

Integrate with ASP.NET Core rate limiting to protect your API:

// Configure rate limiting
builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("DataSurfacePolicy", opt =>
    {
        opt.PermitLimit = 100;
        opt.Window = TimeSpan.FromMinutes(1);
        opt.QueueLimit = 10;
    });
});

// Enable rate limiting on DataSurface endpoints
app.MapDataSurfaceCrud(new DataSurfaceHttpOptions
{
    EnableRateLimiting = true,
    RateLimitingPolicy = "DataSurfacePolicy"
});

// Don't forget to use the rate limiter middleware
app.UseRateLimiter();

Per-Resource Policies:

Configure different policies per resource using [CrudAuthorize]:

[CrudResource("high-traffic")]
[CrudAuthorize(RateLimitingPolicy = "HighTrafficPolicy")]
public class HighTrafficResource { /* ... */ }

API Key Authentication

Enable API key authentication for machine-to-machine access:

app.MapDataSurfaceCrud(new DataSurfaceHttpOptions
{
    EnableApiKeyAuth = true,
    ApiKeyHeaderName = "X-Api-Key"  // Default header name
});

Request:

GET /api/users
X-Api-Key: your-api-key-here

Custom Validation:

Implement IApiKeyValidator for custom validation logic:

using DataSurface.Http;

public class DatabaseApiKeyValidator : IApiKeyValidator
{
    private readonly AppDbContext _db;
    
    public DatabaseApiKeyValidator(AppDbContext db) => _db = db;
    
    public async Task<bool> ValidateAsync(string apiKey, CancellationToken ct)
    {
        return await _db.ApiKeys
            .AnyAsync(k => k.Key == apiKey && k.IsActive && k.ExpiresAt > DateTime.UtcNow, ct);
    }
}

// Register
builder.Services.AddScoped<IApiKeyValidator, DatabaseApiKeyValidator>();

Default Behavior:

  • Without IApiKeyValidator, any non-empty API key is accepted
  • With IApiKeyValidator, the validator determines validity
  • Missing or invalid API keys return HTTP 401 Unauthorized

Audit Logging

Track all CRUD operations using IAuditLogger:

using DataSurface.EFCore.Interfaces;

public class DatabaseAuditLogger : IAuditLogger
{
    private readonly AppDbContext _db;
    private readonly IHttpContextAccessor _http;
    
    public DatabaseAuditLogger(AppDbContext db, IHttpContextAccessor http)
    {
        _db = db;
        _http = http;
    }
    
    public async Task LogAsync(AuditLogEntry entry, CancellationToken ct)
    {
        _db.AuditLogs.Add(new AuditLog
        {
            UserId = _http.HttpContext?.User.FindFirst("sub")?.Value,
            Operation = entry.Operation.ToString(),
            ResourceKey = entry.ResourceKey,
            EntityId = entry.EntityId,
            Timestamp = entry.Timestamp,
            Success = entry.Success,
            Changes = entry.Changes?.ToJsonString(),
            PreviousValues = entry.PreviousValues?.ToJsonString()
        });
        await _db.SaveChangesAsync(ct);
    }
}

// Register
builder.Services.AddScoped<IAuditLogger, DatabaseAuditLogger>();

AuditLogEntry properties:

  • Operation β€” The CRUD operation performed
  • ResourceKey β€” The resource being accessed
  • EntityId β€” The entity ID (if applicable)
  • Timestamp β€” UTC timestamp
  • Success β€” Whether the operation succeeded
  • Changes β€” JSON of fields written (create/update)
  • PreviousValues β€” JSON of previous values (update)

Structured Logging

Both EfDataSurfaceCrudService and DynamicDataSurfaceCrudService emit structured logs:

[DBG] List User page=1 pageSize=20
[DBG] List User completed in 45ms, returned 20/142 items
[INF] Created User in 12ms
[INF] Updated User id=5 in 8ms
[INF] Deleted User id=5 in 3ms

Log levels:

  • Debug β€” Operation start and read completions
  • Information β€” Mutating operations (create, update, delete)

Structured properties:

  • {Resource} β€” Resource key
  • {Id} β€” Entity ID (when applicable)
  • {ElapsedMs} β€” Operation duration
  • {Count} / {Total} β€” List result counts

Metrics

OpenTelemetry-compatible metrics via DataSurfaceMetrics:

// Register metrics
builder.Services.AddSingleton<DataSurfaceMetrics>();

// Configure OpenTelemetry
builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics.AddMeter("DataSurface"));

Available metrics:

MetricTypeDescription
datasurface.operationsCounterTotal CRUD operations by resource and operation
datasurface.errorsCounterFailed operations by resource, operation, and error type
datasurface.operation.durationHistogramOperation duration in milliseconds
datasurface.rows_affectedCounterRows affected by operations

Distributed Tracing

Activity/span integration via DataSurfaceTracing:

// Configure OpenTelemetry
builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing.AddSource("DataSurface"));

Trace attributes:

  • datasurface.resource β€” Resource key
  • datasurface.operation β€” CRUD operation
  • datasurface.entity_id β€” Entity ID (when applicable)
  • datasurface.rows_affected β€” Rows returned/affected
  • datasurface.query.* β€” Query parameters (page, page_size, filter_count, sort_count)

Health Checks

Built-in IHealthCheck implementations:

builder.Services.AddHealthChecks()
    .AddCheck<DataSurfaceDbHealthCheck>("datasurface-db")
    .AddCheck<DataSurfaceContractsHealthCheck>("datasurface-contracts")
    .AddCheck<DynamicMetadataHealthCheck>("datasurface-dynamic-metadata")
    .AddCheck<DynamicContractsHealthCheck>("datasurface-dynamic-contracts");

Health checks:

  • DataSurfaceDbHealthCheck β€” Database connectivity
  • DataSurfaceContractsHealthCheck β€” Static contracts loaded
  • DynamicMetadataHealthCheck β€” Dynamic entity definitions table accessible
  • DynamicContractsHealthCheck β€” Dynamic contracts loaded

Schema Endpoint

Get JSON Schema for any resource:

GET /api/$schema/users

Response:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "urn:datasurface:User",
  "title": "User",
  "type": "object",
  "properties": {
    "id": { "type": "integer", "format": "int32" },
    "email": { "type": "string", "maxLength": 255 },
    "createdAt": { "type": "string", "format": "date-time" }
  },
  "required": ["email"],
  "x-operations": {
    "list": { "enabled": true },
    "get": { "enabled": true },
    "create": { "enabled": true, "requiredOnCreate": ["email"] },
    "update": { "enabled": true },
    "delete": { "enabled": true }
  },
  "x-query": {
    "maxPageSize": 200,
    "filterableFields": ["email", "createdAt"],
    "sortableFields": ["email", "createdAt"]
  }
}

Useful for:

  • Client-side form generation
  • API documentation
  • Contract validation

Attributes Reference

[CrudResource]

Marks a class as a CRUD resource.

[CrudResource("users", 
    ResourceKey = "User",           // Default: class name
    MaxPageSize = 200,              // Default: 200
    MaxExpandDepth = 2,             // Default: 1
    EnableList = true,              // Default: true
    EnableGet = true,               // Default: true
    EnableCreate = true,            // Default: true
    EnableUpdate = true,            // Default: true
    EnableDelete = true)]           // Default: true
public class User { }

[CrudKey]

Marks the primary key property.

[CrudKey(ApiName = "id")]  // Optional: customize API name
public int Id { get; set; }

[CrudField]

Controls field visibility and behavior.

[CrudField(
    CrudDto.Read | CrudDto.Create | CrudDto.Update | CrudDto.Filter | CrudDto.Sort,
    ApiName = "email",              // Optional: customize API name
    RequiredOnCreate = true,        // Validation: required on POST
    Immutable = false,              // If true: rejected on PATCH
    Hidden = false,                 // If true: never exposed
    MinLength = 1,                  // String validation
    MaxLength = 255,                // String validation
    Min = 0,                        // Numeric validation
    Max = 100,                      // Numeric validation
    Regex = @"^[\w@.]+$")]          // Pattern validation
public string Email { get; set; }

CrudDto flags:

FlagEffect
ReadIncluded in GET responses
CreateAccepted in POST body
UpdateAccepted in PATCH body
FilterCan be used in filter[field]=value
SortCan be used in sort=field

[CrudRelation]

Configures navigation property behavior.

[CrudRelation(
    ReadExpandAllowed = true,       // Can use expand=author
    DefaultExpanded = false,        // Auto-expand without asking
    WriteMode = RelationWriteMode.ById,  // How to write
    WriteFieldName = "authorId",    // Field name for writes
    RequiredOnCreate = false)]
public User Author { get; set; }

RelationWriteMode options:

  • None β€” Cannot write relation
  • ById β€” Write via FK field (e.g., authorId)
  • ByIdList β€” Write via ID array (e.g., tagIds)
  • NestedDisabled β€” Nested objects rejected

[CrudConcurrency]

Marks a row version field for optimistic concurrency.

[CrudConcurrency(RequiredOnUpdate = true)]
public byte[] RowVersion { get; set; }

[CrudAuthorize]

Sets authorization policies per operation.

[CrudAuthorize(Policy = "AdminOnly")]  // All operations
[CrudAuthorize(Operation = CrudOperation.Delete, Policy = "SuperAdmin")]
public class User { }

[CrudHidden]

Completely hides a property from the contract.

[CrudHidden]
public string InternalSecret { get; set; }

[CrudIgnore]

Excludes a property from contract generation (use for EF navigation properties you don't want exposed).

Configuration Options

DataSurfaceEfCoreOptions

builder.Services.AddDataSurfaceEfCore(opt =>
{
    opt.AssembliesToScan = [typeof(Program).Assembly];
    
    // All conventions are opt-in (disabled by default)
    opt.AutoRegisterCrudEntities = true;    // Auto-register in DbContext
    opt.EnableSoftDeleteFilter = true;      // Apply IsDeleted filter
    opt.EnableRowVersionConvention = true;  // Configure RowVersion columns
    opt.EnableTimestampConvention = true;   // Auto-populate CreatedAt/UpdatedAt
    opt.UseCamelCaseApiNames = true;        // camelCase API names (default: true)
    
    // Use a feature preset or customize individual features
    opt.Features = DataSurfaceFeatures.Standard;  // Default is Minimal
    
    opt.ContractBuilderOptions.ExposeFieldsOnlyWhenAnnotated = true;
});

DataSurfaceHttpOptions

app.MapDataSurfaceCrud(new DataSurfaceHttpOptions
{
    ApiPrefix = "/api",                     // Route prefix (default)
    MapStaticResources = true,              // Map static entity routes (default: true)
    
    // All advanced features are opt-in (disabled by default)
    MapDynamicCatchAll = true,              // Map /api/d/{route} (default: false)
    DynamicPrefix = "/d",                   // Dynamic route prefix
    MapResourceDiscoveryEndpoint = true,    // GET /api/$resources (default: false)
    EnableEtags = true,                     // ETag response headers (default: false)
    EnableBulkOperations = true,            // Bulk endpoints (default: false)
    EnableStreaming = true,                 // Streaming endpoints (default: false)
    EnableConditionalGet = true,            // If-None-Match/304 (default: false)
    EnablePutForFullUpdate = true,          // PUT for full replacement (default: false)
    EnableImportExport = true,              // Import/export endpoints (default: false)
    
    // Security & infrastructure (opt-in)
    RequireAuthorizationByDefault = false,  // Require auth on all endpoints
    DefaultPolicy = null,                   // Default auth policy
    EnableRateLimiting = false,             // Enable rate limiting
    RateLimitingPolicy = null,              // Rate limiting policy name
    EnableApiKeyAuth = false,               // Enable API key authentication
    ApiKeyHeaderName = "X-Api-Key",         // API key header name
    EnableWebhooks = false,                 // Enable webhook publishing
    ThrowOnRouteCollision = false           // Fail on duplicate routes
});

DataSurfaceDynamicOptions

builder.Services.AddDataSurfaceDynamic(opt =>
{
    opt.Schema = "dbo";                     // DB schema for dynamic tables
    opt.WarmUpContractsOnStart = true;      // Load contracts at startup
});

DataSurfaceAdminOptions

app.MapDataSurfaceAdmin(new DataSurfaceAdminOptions
{
    Prefix = "/admin/ds",                   // Route prefix
    RequireAuthorization = true,            // Require auth (default: true for security)
    Policy = null                           // Auth policy (default: uses default policy)
});

Feature Flags

DataSurface follows an opt-in philosophy β€” advanced features are disabled by default for security and simplicity. Use DataSurfaceFeatures presets or configure individual features:

builder.Services.AddDataSurfaceEfCore(opt =>
{
    // Use a preset (Minimal is the default)
    opt.Features = DataSurfaceFeatures.Minimal;   // Core CRUD only (default)
    opt.Features = DataSurfaceFeatures.Standard;  // Core + security + observability
    opt.Features = DataSurfaceFeatures.Full;      // All features including webhooks
    
    // Or customize individual features (starting from Minimal defaults)
    opt.Features = new DataSurfaceFeatures
    {
        // Core (enabled by default)
        EnableFieldValidation = true,
        EnableDefaultValues = true,
        
        // Advanced features (opt-in)
        EnableComputedFields = true,
        EnableFieldProjection = true,
        EnableTenantIsolation = true,
        EnableRowLevelSecurity = true,
        EnableResourceAuthorization = true,
        EnableFieldAuthorization = true,
        EnableAuditLogging = true,
        EnableMetrics = true,
        EnableTracing = true,
        EnableQueryCaching = true,
        EnableHooks = true,
        EnableOverrides = true,
        EnableWebhooks = true
    };
});

Available Feature Flags:

CategoryFeatureDefaultDescription
Core CRUDEnableFieldValidationβœ…MinLength, MaxLength, Min, Max, Regex, AllowedValues
EnableDefaultValuesβœ…Apply default values on create
EnableComputedFields❌Evaluate computed expressions at read time
EnableFieldProjection❌Support ?fields= query parameter
SecurityEnableTenantIsolation❌[CrudTenant] attribute support
EnableRowLevelSecurity❌IResourceFilter<T> support
EnableResourceAuthorization❌IResourceAuthorizer<T> support
EnableFieldAuthorization❌IFieldAuthorizer support
ObservabilityEnableAuditLogging❌IAuditLogger integration
EnableMetrics❌OpenTelemetry metrics
EnableTracing❌Distributed tracing
CachingEnableQueryCaching❌IQueryResultCache integration
ExtensibilityEnableHooks❌Lifecycle hooks
EnableOverrides❌CRUD operation overrides
IntegrationEnableWebhooks❌Webhook publishing

Presets:

PresetDescriptionUse Case
MinimalCore CRUD + validation onlySimple APIs, microservices, maximum performance (default)
StandardCore + security + observabilityMost production applications
FullAll features enabledFeature-rich applications with webhooks

Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                        HTTP Layer                                β”‚
β”‚  DataSurface.Http: Minimal API mapping, query parsing, ETags    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
                                β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    IDataSurfaceCrudService                       β”‚
β”‚  ListAsync, GetAsync, CreateAsync, UpdateAsync, DeleteAsync     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                    β”‚                       β”‚
                    β–Ό                       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   EfDataSurfaceCrudService   β”‚ β”‚ DynamicDataSurfaceCrudService β”‚
β”‚   DataSurface.EFCore         β”‚ β”‚   DataSurface.Dynamic         β”‚
β”‚   (Static EF entities)       β”‚ β”‚   (JSON records)              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                    β”‚                       β”‚
                    β–Ό                       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    ResourceContract                              β”‚
β”‚  DataSurface.Core: Single source of truth                       β”‚
β”‚  - Fields, Relations, Operations, Query limits, Security        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                    β”‚                       β”‚
                    β–Ό                       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚     ContractBuilder          β”‚ β”‚   DynamicContractBuilder      β”‚
β”‚  (C# attributes β†’ Contract)  β”‚ β”‚  (DB metadata β†’ Contract)     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key abstractions

InterfacePurpose
IDataSurfaceCrudServiceExecutes CRUD operations
IResourceContractProviderProvides contracts by resource key
ICrudHookGlobal lifecycle hooks
ICrudHook<T>Entity-specific lifecycle hooks
ITimestampedAuto-timestamp convention interface
ISoftDeleteSoft-delete convention interface
IResourceFilter<T>Row-level security filtering
IResourceAuthorizer<T>Resource instance authorization
IFieldAuthorizerField-level read/write authorization
IAuditLoggerCRUD operation audit logging

Quick Checklist

Required Setup

  • Add package references (DataSurface.Core, DataSurface.EFCore, DataSurface.Http)
  • Annotate entities with [CrudResource], [CrudKey], [CrudField]
  • Call AddDataSurfaceEfCore() with assemblies to scan
  • Register CrudHookDispatcher, CrudOverrideRegistry, EfDataSurfaceCrudService
  • Register IDataSurfaceCrudService
  • Call app.MapDataSurfaceCrud()

Optional Features

  • Add DataSurface.OpenApi for Swagger schemas
  • Add DataSurface.Admin for runtime entity management
  • Configure DataSurfaceFeatures for selective feature enablement
  • Register IWebhookPublisher for webhook integration
  • Register IAuditLogger for audit logging
  • Register IApiKeyValidator for custom API key validation
  • Register ITenantResolver for custom tenant resolution
  • Register IResourceFilter<T> for row-level security
  • Register IResourceAuthorizer<T> for resource authorization
  • Register IFieldAuthorizer for field-level authorization

Planned Features

The following features are planned for future releases. Contributions are welcome!

High Priority

FeatureDescriptionStatus
GraphQL Endpoint/api/graphql with auto-generated schema from contractsPlanned
Change Data CaptureTrack historical changes with entity versioning and temporal queriesPlanned
Fluent Configurationbuilder.Resource<T>().Field(x => x.Name).Validation(...) syntaxPlanned

Medium Priority

FeatureDescriptionStatus
Cross-backend ExpansionExpand dynamic entities referencing EF entities and vice versaPlanned
Async Job QueueBackground processing for long-running operations with status trackingPlanned
gRPC SupportgRPC endpoints alongside REST for high-performance scenariosPlanned
Real-time UpdatesSignalR/WebSocket integration for live data subscriptionsPlanned
Batch ValidationValidate multiple entities in a single request before commitPlanned

Lower Priority

FeatureDescriptionStatus
OData SupportOData query syntax compatibility ($filter, $select, $expand)Considering
JSON PatchRFC 6902 JSON Patch support for partial updatesConsidering
Conditional CreatesIf-None-Match: * header support for idempotent createsConsidering
Field MaskingAutomatic PII/sensitive data masking in responsesConsidering
Query Cost AnalysisEstimate and limit query complexity before executionConsidering
Multi-database SupportRoute different resources to different databasesConsidering
Optimistic Offline SyncConflict resolution for mobile/offline scenariosConsidering
Schema MigrationsTrack and apply contract changes across environmentsConsidering

Community Suggestions

Have a feature request? Open an issue on GitHub with the enhancement label!