Substratum 1.0.0-beta.122

This is a prerelease version of Substratum.
dotnet add package Substratum --version 1.0.0-beta.122
                    
NuGet\Install-Package Substratum -Version 1.0.0-beta.122
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Substratum" Version="1.0.0-beta.122" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Substratum" Version="1.0.0-beta.122" />
                    
Directory.Packages.props
<PackageReference Include="Substratum" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Substratum --version 1.0.0-beta.122
                    
#r "nuget: Substratum, 1.0.0-beta.122"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Substratum@1.0.0-beta.122
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Substratum&version=1.0.0-beta.122&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Substratum&version=1.0.0-beta.122&prerelease
                    
Install as a Cake Tool

Substratum

Substratum is an opinionated, production-grade application framework built on ASP.NET Core and FastEndpoints. It eliminates the boilerplate required to bootstrap a modern web API — authentication, authorization, database, caching, logging, OpenAPI docs, cloud storage, spreadsheets, and push notifications are all pre-wired and ready to go.

Write your business logic. Substratum handles the rest.


Table of Contents


Packages

Package NuGet Description
Substratum NuGet Runtime library — everything your app needs
Substratum.Generator NuGet Source generators — zero reflection at runtime
Substratum.Tools NuGet CLI tool (dotnet-sub) — scaffold projects, endpoints, entities, migrations

Install all three into your project:

<PackageReference Include="Substratum" Version="1.0.0-beta.96" />
<PackageReference Include="Substratum.Generator" Version="1.0.0-beta.96"
    OutputItemType="Analyzer" ReferenceOutputAssembly="false" />

Install the CLI tool globally:

dotnet tool install --global Substratum.Tools

Quick Start

1. Create a new project

dotnet-sub new webapp MyApp

This scaffolds a complete project with Program.cs, appsettings.json, security, entities, and a sample endpoint.

2. Or add to an existing project

Create a Program.cs file:

return await Substratum.SubstratumApp.RunAsync(args);

That's it. One line of code boots your entire application. The source generators wire everything else automatically.

3. Add your appsettings.json

{
  "Authentication": {
    "JwtBearer": {
      "Enabled": true,
      "Options": {
        "SecretKey": "YOUR_SECRET_KEY_MUST_BE_AT_LEAST_32_CHARACTERS_LONG",
        "Issuer": "http://localhost:5000",
        "Audience": "MyApp",
        "Expiration": "1.00:00:00"
      }
    }
  },
  "EntityFramework": {
    "Default": {
      "Provider": "Npgsql",
      "ConnectionString": "Host=localhost;Database=mydb;Username=postgres;Password=password"
    }
  }
}

4. Create your first endpoint

using Substratum;

public class GetUsersEndpoint : BaseEndpoint<GetUsersRequest, List<UserDto>>
{
    public override void Configure()
    {
        Get("/api/users");
        AllowAnonymous();
    }

    public override Task<Result<List<UserDto>>> ExecuteAsync(GetUsersRequest req, CancellationToken ct)
    {
        var users = new List<UserDto> { new() { Name = "John" } };
        return Task.FromResult(Success("Users retrieved", users));
    }
}

How It Works

The FeatureOptions Pattern

Most features in Substratum follow the FeatureOptions pattern. This means each feature can be enabled or disabled from your configuration:

public sealed class FeatureOptions<TOptions> where TOptions : class, new()
{
    public bool Enabled { get; set; }        // Toggles the feature on/off
    public TOptions Options { get; init; }   // Feature-specific settings
}

In your appsettings.json, this looks like:

{
  "FeatureName": {
    "Enabled": true,
    "Options": {
      "Setting1": "value",
      "Setting2": 42
    }
  }
}

Features using this pattern: OpenApi, StaticFiles, HealthChecks, Minio, DistributedCache, ResponseCompression, ForwardedHeaders, FileStorage, RateLimiting, Spreadsheet, AWS S3, AWS SecretsManager, Azure BlobStorage, Firebase Messaging, Firebase AppCheck.

Features always active (no Enabled toggle): Cors, Authentication, EntityFramework, ErrorHandling, Localization, RequestLimits.

Source Generators Overview

Substratum uses 8 incremental source generators that analyze your code at compile time and generate boilerplate automatically. This means:

  • Zero reflection at runtime — all type discovery happens at compile time
  • Better performance — no startup scanning of assemblies
  • AOT-compatible — works with Native AOT and trimming
  • Compile-time validation — errors are caught before your app runs

The source generators handle: app bootstrapping, endpoint discovery, service registration, permission registries, localization, endpoint summaries, document groups, and spreadsheet metadata.


Configuration

The recommended way to configure Substratum is through appsettings.json. All options are bound automatically from the configuration:

{
  "ServerEnvironment": "Development",
  "Cors": { ... },
  "Authentication": { ... },
  "EntityFramework": { ... },
  "ErrorHandling": { ... },
  "Localization": { ... },
  "OpenApi": { ... },
  "StaticFiles": { ... },
  "HealthChecks": { ... },
  "Minio": { ... },
  "Aws": { ... },
  "Azure": { ... },
  "Firebase": { ... },
  "DistributedCache": { ... },
  "ResponseCompression": { ... },
  "ForwardedHeaders": { ... },
  "RequestLimits": { ... },
  "FileStorage": { ... },
  "RateLimiting": { ... },
  "Spreadsheet": { ... }
}

C# Configuration

You can also configure options programmatically:

return await SubstratumApp.RunAsync(args, options =>
{
    options.ServerEnvironment = ServerEnvironment.Development;
    options.Authentication.JwtBearer.Enabled = true;
    options.Authentication.JwtBearer.Options.SecretKey = "my-secret-key";

    // Register additional services
    options.Services.AddSingleton<IMyService, MyService>();
});

The configure callback runs after appsettings.json is loaded, so you can override any JSON setting from code. You can also register additional DI services through options.Services.

Server Environment

public enum ServerEnvironment
{
    Development,
    Staging,
    UAT,
    Production
}

Set it in appsettings.json:

{
  "ServerEnvironment": "Development"
}

Use in code:

if (options.ServerEnvironment.IsDevelopment())
{
    // Dev-only logic
}

Extension methods: IsDevelopment(), IsStaging(), IsUAT(), IsProduction().


Core Library

Endpoints

All endpoints extend BaseEndpoint<TRequest, TResponse>, which provides a structured API built on FastEndpoints:

public abstract class BaseEndpoint<TRequest, TResponse> : Endpoint<TRequest, Result<TResponse>>
    where TRequest : notnull
{
    // You must implement these two methods:
    public abstract override void Configure();
    public abstract override Task<Result<TResponse>> ExecuteAsync(TRequest req, CancellationToken ct);

    // Helper methods available in your endpoints:
    protected Result<TResponse> Success(string message, TResponse data);
    protected Result<TResponse> Failure(int statusCode, string message, IReadOnlyList<string>? errors = null);

    // Permission methods (type-safe — accept PermissionDefinition objects):
    protected void PermissionsAny(params PermissionDefinition[] permissions);
    protected void PermissionsAll(params PermissionDefinition[] permissions);

    // Document group assignment:
    protected void DocGroup(params DocGroupDefinition[] groups);
}
Full Endpoint Example

Every Substratum endpoint consists of up to 6 files:

1. Request class — input data:

public class ListUsersRequest
{
    public int PageNumber { get; set; } = 1;
    public int PageSize { get; set; } = 10;
}

2. Response class — output data:

public class ListUsersResponse
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
}

3. Validator — input validation using FluentValidation:

public class ListUsersRequestValidator : Validator<ListUsersRequest>
{
    public ListUsersRequestValidator()
    {
        RuleFor(x => x.PageNumber).GreaterThan(0);
        RuleFor(x => x.PageSize).InclusiveBetween(1, 100);
    }
}

4. Serializer context — for AOT-compatible JSON serialization:

[JsonSerializable(typeof(ListUsersRequest))]
[JsonSerializable(typeof(Result<PaginatedResult<ListUsersResponse>>))]
[JsonSerializable(typeof(Result<Unit>))]
public partial class ListUsersSerializerContext : JsonSerializerContext { }

5. Summary — OpenAPI documentation with localization:

public partial class ListUsersSummary : SubstratumEndpointSummary
{
    protected override void Configure(IStringLocalizer localizer)
    {
        Description = "Lists all users with pagination.";
    }
}

6. Endpoint — the actual handler:

public class ListUsersEndpoint : BaseEndpoint<ListUsersRequest, PaginatedResult<ListUsersResponse>>
{
    private readonly AppDbContext _db;
    private readonly IStringLocalizer<SharedResource> _localizer;

    public ListUsersEndpoint(AppDbContext db, IStringLocalizer<SharedResource> localizer)
    {
        _db = db;
        _localizer = localizer;
    }

    public override void Configure()
    {
        Version(1);
        Get("/api/v{version}/users");
        PermissionsAll(AppPermissions.Users_List);
        SerializerContext<ListUsersSerializerContext>();
        Summary(new ListUsersSummary(_localizer));
    }

    public override async Task<Result<PaginatedResult<ListUsersResponse>>> ExecuteAsync(
        ListUsersRequest req, CancellationToken ct)
    {
        var result = await PaginatedResult<ListUsersResponse>.CreateAsync(
            _db.Users.Select(u => new ListUsersResponse
            {
                Id = u.Id, Name = u.Name, Email = u.Email
            }),
            req.PageNumber, req.PageSize, ct);

        return Success(_localizer["DataRetrievedSuccessfully"], result);
    }
}

Note: The source generator automatically discovers your endpoints, registers them with FastEndpoints, and generates endpoint summaries. You don't need to manually wire anything.

Result Pattern

All endpoints return Result<T>:

public sealed class Result<T>
{
    public int Code { get; init; }                    // 0 = success, non-zero = error
    public string Message { get; init; }              // Human-readable message
    public T? Data { get; init; }                     // Response payload
    public IReadOnlyList<string>? Errors { get; init; } // Validation/error details
}

Success response example:

{
  "code": 0,
  "message": "Users retrieved successfully.",
  "data": [ ... ],
  "errors": null
}

Error response example:

{
  "code": 1,
  "message": "Validation failed.",
  "data": null,
  "errors": ["PageNumber must be greater than 0"]
}

Pagination

Substratum includes a built-in PaginatedResult<T> class that works with EF Core's IQueryable<T>:

public sealed class PaginatedResult<T>
{
    public int PageNumber { get; }
    public int TotalPages { get; }
    public int TotalCount { get; }
    public IReadOnlyCollection<T> Items { get; }
    public bool HasPreviousPage { get; }    // true if PageNumber > 1
    public bool HasNextPage { get; }        // true if PageNumber < TotalPages

    // Create from IQueryable (executes COUNT + SKIP/TAKE):
    public static Task<PaginatedResult<T>> CreateAsync(
        IQueryable<T> source, int pageNumber, int pageSize, CancellationToken ct = default);

    // Create with projection (map entity to DTO):
    public static Task<PaginatedResult<TResult>> CreateAsync<TSource, TResult>(
        IQueryable<TSource> source, Expression<Func<TSource, TResult>> selector,
        int pageNumber, int pageSize, CancellationToken ct = default);
}

Usage:

// Simple pagination:
var result = await PaginatedResult<User>.CreateAsync(
    _db.Users.OrderBy(u => u.Name),
    pageNumber: 1, pageSize: 10, ct);

// With projection (map entity to DTO in the query):
var result = await PaginatedResult<UserDto>.CreateAsync(
    _db.Users.OrderBy(u => u.Name),
    u => new UserDto { Id = u.Id, Name = u.Name },
    pageNumber: 1, pageSize: 10, ct);

Base Entity

All your database entities should extend BaseEntity<T>:

public abstract class BaseEntity<T> : BaseEntity where T : struct
{
    public T Id { get; init; }
}

public abstract class BaseEntity
{
    public bool IsDeleted { get; set; }           // Soft delete flag
    public DateTimeOffset CreatedAt { get; set; }
    public DateTimeOffset UpdatedAt { get; set; }
    public DateTimeOffset? DeletedAt { get; set; } // Automatically set when IsDeleted = true
}

When you set IsDeleted = true, the DeletedAt timestamp is automatically set to DateTimeOffset.UtcNow. When you set IsDeleted = false, DeletedAt is automatically cleared to null.

Example entity:

public sealed class User : BaseEntity<Guid>
{
    public string? Name { get; set; }
    public string? Email { get; set; }
    public string? PhoneNumber { get; set; }
    public Guid RoleId { get; set; }
    public Role Role { get; private set; } = null!;
}

public sealed class Role : BaseEntity<Guid>
{
    public string Name { get; set; } = string.Empty;
    public ICollection<User> Users { get; private set; } = new List<User>();
}

Unit Type

When your endpoint doesn't return any data, use Unit as the response type:

public sealed class Unit { }

Usage:

public class DeleteUserEndpoint : BaseEndpoint<DeleteUserRequest, Unit>
{
    public override async Task<Result<Unit>> ExecuteAsync(DeleteUserRequest req, CancellationToken ct)
    {
        // ... delete logic
        return Success("User deleted", new Unit());
    }
}

Authentication

Substratum supports four authentication schemes that can be enabled independently. When multiple schemes are enabled, they're combined into a single Substratum policy scheme that tries each scheme in order.

JWT Bearer

Enable JWT authentication in appsettings.json:

{
  "Authentication": {
    "JwtBearer": {
      "Enabled": true,
      "Options": {
        "SecretKey": "YOUR_SECRET_KEY_MUST_BE_AT_LEAST_32_CHARACTERS_LONG",
        "Issuer": "http://localhost:5000",
        "Audience": "MyApp",
        "Expiration": "1.00:00:00",
        "RefreshExpiration": "7.00:00:00",
        "ClockSkew": "00:02:00",
        "RequireHttpsMetadata": true
      }
    }
  }
}
JWT Bearer Options
Property Type Default Description
SecretKey string "" HMAC-SHA256 signing key. Must be at least 32 characters.
Issuer string "" Token issuer (iss claim).
Audience string "" Token audience (aud claim).
Expiration TimeSpan Access token lifetime. Format: days.hours:minutes:seconds
RefreshExpiration TimeSpan 7.00:00:00 Refresh token lifetime (7 days default).
ClockSkew TimeSpan 00:02:00 Allowed clock difference (2 minutes default).
RequireHttpsMetadata bool true Require HTTPS for metadata endpoint.
Using IJwtBearer

Inject IJwtBearer to create and manage tokens:

public interface IJwtBearer
{
    // Create an access token (no refresh):
    (string AccessToken, Guid SessionId, DateTimeOffset Expiration) CreateToken(Guid userId);
    (string AccessToken, Guid SessionId, DateTimeOffset Expiration) CreateToken(Guid userId, string appId);

    // Create access + refresh token pair (requires IRefreshTokenStore):
    Task<(string AccessToken, string RefreshToken, Guid SessionId,
        DateTimeOffset AccessExpiration, DateTimeOffset RefreshExpiration)>
        CreateTokenPairAsync(Guid userId, CancellationToken ct = default);
    Task<(string AccessToken, string RefreshToken, Guid SessionId,
        DateTimeOffset AccessExpiration, DateTimeOffset RefreshExpiration)>
        CreateTokenPairAsync(Guid userId, string appId, CancellationToken ct = default);

    // Refresh an expired access token:
    Task<(string AccessToken, string RefreshToken,
        DateTimeOffset AccessExpiration, DateTimeOffset RefreshExpiration)?>
        RefreshAsync(string refreshToken, CancellationToken ct = default);
    Task<(string AccessToken, string RefreshToken,
        DateTimeOffset AccessExpiration, DateTimeOffset RefreshExpiration)?>
        RefreshAsync(string refreshToken, string appId, CancellationToken ct = default);
}

Example — simple login:

var (token, sessionId, expiration) = jwtBearer.CreateToken(user.Id);
return Success("Login successful", new LoginResponse
{
    AccessToken = token,
    SessionId = sessionId,
    Expiration = expiration
});

Refresh Tokens

To use refresh tokens, implement IRefreshTokenStore:

public interface IRefreshTokenStore
{
    Task StoreAsync(Guid userId, Guid sessionId, string tokenHash,
        DateTimeOffset expiration, CancellationToken ct);
    Task<RefreshTokenValidationResult?> ValidateAndRevokeAsync(string tokenHash, CancellationToken ct);
    Task RevokeBySessionAsync(Guid sessionId, CancellationToken ct);
    Task RevokeAllAsync(Guid userId, CancellationToken ct);
}

public sealed class RefreshTokenValidationResult
{
    public Guid UserId { get; init; }
    public Guid SessionId { get; init; }
}

Once implemented, the source generator auto-registers it. Then use token pairs:

// Create access + refresh:
var result = await jwtBearer.CreateTokenPairAsync(user.Id, ct);
// result.AccessToken, result.RefreshToken, result.SessionId, etc.

// Refresh later:
var refreshed = await jwtBearer.RefreshAsync(refreshToken, ct);
if (refreshed is null) return Failure(401, "Invalid refresh token");

Token rotation is built-in: each refresh invalidates the old token and issues a new pair (rotate-on-use).

Enable cookie authentication:

{
  "Authentication": {
    "Cookie": {
      "Enabled": true,
      "Options": {
        "Scheme": "Cookies",
        "CookieName": ".Substratum.Auth",
        "Expiration": "365.00:00:00",
        "SlidingExpiration": true,
        "Secure": true,
        "HttpOnly": true,
        "SameSite": "Lax",
        "AppIdHeaderName": "X-APP-ID"
      }
    }
  }
}
Property Type Default Description
Scheme string "" Authentication scheme name.
CookieName string "" Cookie name sent to browser.
Expiration TimeSpan Cookie lifetime.
SlidingExpiration bool false Renew cookie on each request.
Secure bool false Require HTTPS for cookie.
HttpOnly bool false Prevent JavaScript access.
SameSite SameSiteMode Lax Cookie SameSite policy (Strict, Lax, None).
AppIdHeaderName string "X-APP-ID" Header name for app ID extraction.
Using ICookieAuth
public interface ICookieAuth
{
    Task<(Guid SessionId, DateTimeOffset Expiration)> SignInAsync(
        HttpContext httpContext, Guid userId, CancellationToken ct = default);
    Task<(Guid SessionId, DateTimeOffset Expiration)> SignInAsync(
        HttpContext httpContext, Guid userId, string appId, CancellationToken ct = default);
    Task SignOutAsync(HttpContext httpContext, CancellationToken ct = default);
    Task SignOutAsync(HttpContext httpContext, string appId, CancellationToken ct = default);
}

Example:

var (sessionId, expiration) = await cookieAuth.SignInAsync(HttpContext, user.Id, ct);

Basic Authentication

Enable HTTP Basic authentication:

{
  "Authentication": {
    "BasicAuthentication": {
      "Enabled": true,
      "Options": {
        "Realm": "MyApp"
      }
    }
  }
}

You must implement IBasicAuthValidator:

public interface IBasicAuthValidator
{
    Task<(bool Result, string UserId, string SessionId)> ValidateAsync(
        HttpContext context, string username, string password, CancellationToken cancellationToken);
}

Access Key

Enable API key authentication:

{
  "Authentication": {
    "AccessKeyAuthentication": {
      "Enabled": true,
      "Options": {
        "Realm": "MyApp",
        "KeyName": "X-API-KEY"
      }
    }
  }
}

You must implement IAccessKeyValidator:

public interface IAccessKeyValidator
{
    Task<(bool Result, string UserId, string SessionId)> ValidateAsync(
        HttpContext context, string accessKey, CancellationToken cancellationToken);
}

Combined Authentication

When multiple schemes are enabled, Substratum creates a combined "Substratum" policy scheme. The request is authenticated against whichever scheme matches. You don't need to configure this — it's automatic.

The scheme names are available as constants:

public static class SubstratumAuthSchemes
{
    public const string Combined = "Substratum";        // Policy scheme (default)
    public const string Bearer = "Bearer";              // JWT Bearer
    public const string Cookies = "Cookies";            // Cookie auth
    public const string Basic = "Basic";                // Basic auth
    public const string AccessKey = "ApiKey";           // API key auth
}

App-Scoped Authentication

Substratum supports multi-app authentication. This is useful when a single backend serves multiple frontend applications (web, mobile, admin panel, etc.).

Pass appId when creating tokens:

var (token, sessionId, expiration) = jwtBearer.CreateToken(userId, appId: "mobile-app");

The appId is stored as a claim and available via ICurrentUser.AppId.

For cookie auth, Substratum reads the AppIdHeaderName header (default: X-APP-ID) from the request.

You can implement IAppResolver to validate app IDs:

public interface IAppResolver
{
    Task<bool> ValidateAsync(string appId, CancellationToken ct = default);
}

Two-Factor Authentication (TOTP)

Substratum includes a TOTP (Time-Based One-Time Password) provider for 2FA:

public interface ITotpProvider
{
    string GenerateSecret();
    string GenerateQrCodeUri(string secret, string accountName, string issuer);
    bool ValidateCode(string secret, string code);
}

Example — setup 2FA:

// Generate secret for user:
var secret = totpProvider.GenerateSecret();
// Save secret to database...

// Generate QR code URI for authenticator app:
var qrUri = totpProvider.GenerateQrCodeUri(secret, "user@example.com", "MyApp");
// Return qrUri to the frontend to display as QR code

// Validate code from authenticator app:
bool isValid = totpProvider.ValidateCode(secret, "123456");

The TOTP provider uses HMAC-SHA1, 6-digit codes, 30-second time steps, and RFC-specified network delay window.

Current User

Inject ICurrentUser to access the authenticated user:

public interface ICurrentUser
{
    Guid? UserId { get; }                // Parsed from "uid" claim
    string? AppId { get; }               // Parsed from "aid" claim
    PermissionDefinition[] Permissions { get; }  // Resolved from "permissions" claims
}
Property Source Description
UserId uid claim The authenticated user's ID (GUID). null if not authenticated.
AppId aid claim The application ID when multi-app auth is used. null if not set.
Permissions permissions claims All PermissionDefinition objects for the current user. Empty array if not authenticated or no permissions hydrated.

The Permissions property reads all claims with type "permissions" from the current ClaimsPrincipal and resolves each permission code back to its PermissionDefinition using the source-generated TryParse method from your IPermissionRegistry implementation. This means:

  • Permissions are only available after the IPermissionHydrator has run (which happens automatically via the claims transformer).
  • Unknown permission codes are silently skipped.
  • The result is always a fresh array (not cached), so it reflects the current claims state.

Example:

public class MyEndpoint : BaseEndpoint<MyRequest, MyResponse>
{
    private readonly ICurrentUser _currentUser;

    public MyEndpoint(ICurrentUser currentUser) => _currentUser = currentUser;

    public override async Task<Result<MyResponse>> ExecuteAsync(MyRequest req, CancellationToken ct)
    {
        var userId = _currentUser.UserId;
        if (userId is null)
            return Failure(401, "Not authenticated");

        // Access the user's permissions
        var permissions = _currentUser.Permissions;
        var canViewAll = permissions.Any(p => p.Code == AppPermissions.DocumentsViewAll.Code);
        // ...
    }
}

Password Hasher

public interface IPasswordHasher
{
    string HashPassword(string password);
    bool VerifyHashedPassword(string hashedPassword, string providedPassword, out bool needsRehash);
}

The password hasher uses PBKDF2 with HMAC-SHA256, 600,000 iterations, 16-byte salt, and 32-byte subkey. It's a singleton and also available as PasswordHasher.Instance for static access.

Example:

// Hash a new password:
var hash = passwordHasher.HashPassword("mysecretpassword");

// Verify a password:
bool isValid = passwordHasher.VerifyHashedPassword(hash, "mysecretpassword", out bool needsRehash);

// If needsRehash is true, re-hash with current parameters:
if (isValid && needsRehash)
{
    var newHash = passwordHasher.HashPassword("mysecretpassword");
    // Save newHash to database
}

Claims Types

Substratum uses short claim names for compact tokens:

public static class SubstratumClaimsTypes
{
    public const string UserId = "uid";
    public const string SessionId = "sid";
    public const string AppId = "aid";
}

Supporting Interfaces

ISessionValidator

Validate that a session is still active (e.g., not revoked):

public interface ISessionValidator
{
    Task<bool> ValidateAsync(HttpContext context, string userId, string sessionId);
}

When implemented, this is called on every authenticated request to verify the session hasn't been revoked.

IPermissionHydrator

Load user permissions into the claims principal:

public interface IPermissionHydrator
{
    Task HydrateAsync(IServiceProvider serviceProvider, ClaimsPrincipal principal,
        CancellationToken cancellationToken);
}

This is called after authentication to add permission claims that PermissionsAny/PermissionsAll check against.


Permissions

Substratum has a compile-time permission system. Define your permissions as static fields:

public static partial class AppPermissions
{
    public static readonly PermissionDefinition Users_List = new(
        code: "users.list",
        name: "Users_List",
        displayName: "List Users",
        groupCode: "users",
        groupName: "Users",
        groupDisplayName: "User Management",
        description: "View the list of all users"
    );

    public static readonly PermissionDefinition Users_Create = new(
        code: "users.create",
        name: "Users_Create",
        displayName: "Create User",
        groupCode: "users",
        groupName: "Users",
        groupDisplayName: "User Management"
    );
}
PermissionDefinition Properties
Property Type Description
Code string Unique permission code (e.g., "users.list"). Stored in claims.
Name string C# member name (e.g., "Users_List").
DisplayName string Human-friendly name for UI display.
GroupCode string Group identifier for organizing permissions.
GroupName string Group name.
GroupDisplayName string Human-friendly group name for UI display.
GroupNameSegmentLength int How many segments of the name to use for grouping.
Description string? Optional description.

The Permissions source generator automatically:

  1. Discovers all PermissionDefinition static fields
  2. Generates an IPermissionRegistry implementation
  3. Generates Parse(string code) and TryParse(string code, out PermissionDefinition? result) methods
  4. Generates a Definitions() method that returns all permissions

Use permissions in endpoints:

public override void Configure()
{
    Get("/api/users");
    PermissionsAll(AppPermissions.Users_List);                    // Require ALL listed permissions
    PermissionsAny(AppPermissions.Users_List, AppPermissions.Users_Create); // Require ANY of the listed permissions
}

Document Groups

Document groups let you organize your API into separate Swagger/Scalar documents. Define them as static fields:

public static partial class DocumentGroups
{
    public static readonly DocGroupDefinition PublicApi = new(
        name: "Public API",
        url: "public",
        isDefault: true,
        description: "Public-facing endpoints"
    );

    public static readonly DocGroupDefinition AdminApi = new(
        name: "Admin API",
        url: "admin",
        permission: AppPermissions.Admin_Access,
        description: "Administrative endpoints"
    );
}

The DocGroup source generator discovers these and generates an IDocGroupRegistry implementation. Assign endpoints to groups:

public override void Configure()
{
    Get("/api/admin/users");
    DocGroup(DocumentGroups.AdminApi);
}

If a DocGroupDefinition has a Permission, Substratum protects that document group endpoint. You need either IAccessKeyValidator or IBasicAuthValidator to access protected doc groups.


Entity Framework

Single DbContext

Define your DbContext:

public class AppDbContext : DbContext
{
    public DbSet<User> Users => Set<User>();
    public DbSet<Role> Roles => Set<Role>();

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}

Configure in appsettings.json:

{
  "EntityFramework": {
    "Default": {
      "Provider": "Npgsql",
      "ConnectionString": "Host=localhost;Database=mydb;Username=postgres;Password=password",
      "CommandTimeoutSeconds": 30,
      "EnableSeeding": true,
      "Logging": {
        "EnableDetailedErrors": true,
        "EnableSensitiveDataLogging": true
      },
      "RetryPolicy": {
        "Enabled": true,
        "Options": {
          "MaxRetryCount": 3,
          "MaxRetryDelaySeconds": 5
        }
      },
      "SecondLevelCache": {
        "Enabled": true,
        "Options": {
          "KeyPrefix": "EF_",
          "Provider": "Memory"
        }
      }
    }
  }
}

The source generator auto-discovers your DbContext and wires it with the configuration. It uses the [DbContextName("Default")] attribute to match DbContexts to configuration sections:

[DbContextName("Default")]
public class AppDbContext : DbContext { ... }

If you only have one DbContext, the attribute is optional — it defaults to "Default".

Multiple DbContexts

You can configure multiple database connections:

{
  "EntityFramework": {
    "Default": {
      "Provider": "Npgsql",
      "ConnectionString": "Host=localhost;Database=main_db;..."
    },
    "Analytics": {
      "Provider": "SqlServer",
      "ConnectionString": "Server=localhost;Database=analytics_db;..."
    }
  }
}

Use [DbContextName] to match each DbContext to its configuration:

[DbContextName("Default")]
public class AppDbContext : DbContext { ... }

[DbContextName("Analytics")]
public class AnalyticsDbContext : DbContext { ... }

Database Providers

public enum EntityFrameworkProviders
{
    SqlServer,    // Microsoft SQL Server
    Npgsql,       // PostgreSQL
    Sqlite        // SQLite
}

All providers automatically get:

  • snake_case naming convention (EFCore.NamingConventions)
  • Check constraints (EFCore.CheckConstraints)
  • Retry policy (when enabled)
  • Second-level cache (when enabled)

Database Seeding

When EnableSeeding is true, Substratum looks for an IDbContextInitializer<TDbContext> implementation:

public interface IDbContextInitializer<in TDbContext> where TDbContext : DbContext
{
    Task SeedAsync(TDbContext dbContext, CancellationToken ct = default);
}

Example:

public class AppDbContextInitializer : IDbContextInitializer<AppDbContext>
{
    public async Task SeedAsync(AppDbContext dbContext, CancellationToken ct = default)
    {
        if (!await dbContext.Roles.AnyAsync(ct))
        {
            dbContext.Roles.Add(new Role { Id = Guid.NewGuid(), Name = "Admin" });
            await dbContext.SaveChangesAsync(ct);
        }
    }
}

There's also a non-generic IDbContextInitializer that receives a base DbContext:

public interface IDbContextInitializer
{
    Task SeedAsync(DbContext dbContext, CancellationToken ct = default);
}

Retry Policy

Automatically retries failed database operations (transient failures):

{
  "RetryPolicy": {
    "Enabled": true,
    "Options": {
      "MaxRetryCount": 3,
      "MaxRetryDelaySeconds": 5
    }
  }
}
Property Type Default Description
MaxRetryCount int 3 Maximum retry attempts.
MaxRetryDelaySeconds int 5 Maximum delay between retries in seconds.

Second-Level Cache

Caches EF Core query results to avoid repeated database roundtrips:

{
  "SecondLevelCache": {
    "Enabled": true,
    "Options": {
      "KeyPrefix": "EF_",
      "Provider": "Memory"
    }
  }
}
Property Type Default Description
KeyPrefix string "" Prefix for cache keys (useful to avoid collisions).
Provider SecondLevelCacheProviders Memory Memory or Redis.
Redis.ConnectionString string "" Redis connection string (only when Provider = Redis).
Redis.TimeoutSeconds int 3 Redis operation timeout.

Entity Framework Configuration Reference

Property Type Default Description
Provider EntityFrameworkProviders SqlServer Database provider.
ConnectionString string "" Database connection string.
CommandTimeoutSeconds int 30 Command timeout in seconds.
EnableSeeding bool false Enable database seeding on startup.
DbContextOptionsBuilder Action null Custom DbContext options (C# only).
Logging.EnableDetailedErrors bool false Show detailed EF Core errors.
Logging.EnableSensitiveDataLogging bool false Log parameter values (dev only!).

OpenAPI Documentation

Substratum auto-configures OpenAPI with Scalar UI:

{
  "OpenApi": {
    "Enabled": true,
    "Options": {
      "Servers": [
        {
          "Url": "https://localhost:5000",
          "Description": "Local Development"
        }
      ]
    }
  }
}

When enabled, your API documentation is available at /scalar/{version}. The EndpointSummary source generator auto-generates summary classes and wires localized descriptions.


Localization

Substratum supports multi-language applications using .resx resource files.

1. Create resource files:

  • Resources/SharedResource.en.resx (English)
  • Resources/SharedResource.ar.resx (Arabic)

2. Create a marker class:

public class SharedResource { }

3. The Localization source generator automatically discovers your .resx files and generates a [ModuleInitializer] that registers:

  • The resource source type
  • The default culture
  • All supported cultures (detected from .resx file names)

4. Use in endpoints:

public class MyEndpoint : BaseEndpoint<MyRequest, MyResponse>
{
    private readonly IStringLocalizer<SharedResource> _localizer;

    public MyEndpoint(IStringLocalizer<SharedResource> localizer)
    {
        _localizer = localizer;
    }

    public override async Task<Result<MyResponse>> ExecuteAsync(MyRequest req, CancellationToken ct)
    {
        return Success(_localizer["DataRetrievedSuccessfully"], data);
    }
}

5. Configure default culture in appsettings.json:

{
  "Localization": {
    "DefaultCulture": "en"
  }
}

Spreadsheet

Substratum includes a powerful, source-generator-powered spreadsheet engine built on EPPlus. Decorate your classes with attributes, and Substratum generates all the metadata at compile time — zero reflection at runtime.

Spreadsheet Quick Start

1. Enable in appsettings.json:

{
  "Spreadsheet": {
    "Enabled": true,
    "Options": {
      "LicenseType": "NonCommercial"
    }
  }
}

2. Decorate your class with attributes:

using Substratum.Spreadsheet;

[Sheet(Name = "Employee Report", AutoFilter = true, FreezeHeader = true)]
public class EmployeeRow
{
    [Column(Name = "Employee ID", Order = 1, Width = 15)]
    [HeaderStyle(Bold = true, BackgroundColor = "#2B5797", FontColor = "#FFFFFF")]
    public int Id { get; set; }

    [Column(Name = "Full Name", Order = 2, Width = 25)]
    public string Name { get; set; } = "";

    [Column(Name = "Department", Order = 3)]
    [Dropdown(Values = new[] { "Engineering", "Sales", "HR", "Finance" })]
    public string Department { get; set; } = "";

    [Column(Name = "Salary", Order = 4, Format = "#,##0.00")]
    [ConditionalFormat(When = "> 100000", BackgroundColor = "#C6EFCE", FontColor = "#006100")]
    [ConditionalFormat(When = "< 50000", BackgroundColor = "#FFC7CE", FontColor = "#9C0006")]
    public decimal Salary { get; set; }

    [Column(Name = "Active", Order = 5)]
    [BooleanFormat(TrueValue = "Yes", FalseValue = "No")]
    public bool IsActive { get; set; }

    [Column(Name = "Hire Date", Order = 6)]
    public DateTime HireDate { get; set; }
}

3. Export to Excel:

public class ExportEndpoint : BaseEndpoint<EmptyRequest, Unit>
{
    private readonly ISpreadsheet _spreadsheet;

    public ExportEndpoint(ISpreadsheet spreadsheet) => _spreadsheet = spreadsheet;

    public override async Task<Result<Unit>> ExecuteAsync(EmptyRequest req, CancellationToken ct)
    {
        var employees = GetEmployees();
        var file = _spreadsheet.ToFile(employees, "employees.xlsx");

        await SendBytesAsync(file.GetBytes(), file.FileName, file.ContentType, cancellation: ct);
        return Success("Exported", new Unit());
    }
}

Attributes Reference

Sheet Attribute

Applied to a class to configure the sheet:

[Sheet(
    Name = "Report",              // Sheet tab name (default: class name sans "Row"/"Sheet"/"Export" suffix)
    RightToLeft = false,          // RTL layout for Arabic/Hebrew
    AutoFilter = true,            // Enable auto-filter dropdowns on all columns
    FreezeHeader = true,          // Freeze the first row
    TabColor = "#FF0000",         // Sheet tab color (HTML color)
    Protection = SheetProtectionMode.None, // None, Password, or Sealed
    Password = null,              // Protection password (when Protection = Password)
    AllowSort = true,             // Allow sorting in protected sheets
    AllowFilter = true,           // Allow filtering in protected sheets
    AllowSelectLockedCells = true,
    AllowSelectUnlockedCells = true
)]
public class MyRow { ... }
Column Attribute

Applied to properties to configure columns:

[Column(
    Name = "Column Header",    // Header text (default: property name)
    Order = 1,                 // Column position (1-based). Lower numbers appear first.
    Width = 20,                // Column width in characters (0 = auto-fit)
    Format = "#,##0.00",       // Excel number format string
    Hidden = false,            // Hide the column
    WrapText = false           // Enable text wrapping in cells
)]
public string MyProperty { get; set; }
HeaderStyle Attribute

Applied to properties to style the header cell:

[HeaderStyle(
    Bold = true,
    Italic = false,
    Underline = false,
    BackgroundColor = "#2B5797",    // HTML color
    FontColor = "#FFFFFF",          // HTML color
    FontSize = 14,
    FontName = "Calibri",
    Alignment = HorizontalAlignment.Center  // Left, Center, Right
)]
public string MyProperty { get; set; }
CellStyle Attribute

Applied to properties to style all data cells in that column:

[CellStyle(
    Bold = false,
    Italic = true,
    BackgroundColor = "#F2F2F2",
    FontColor = "#333333",
    FontSize = 11,
    FontName = "Calibri",
    Alignment = HorizontalAlignment.Right
)]
public decimal Amount { get; set; }
ConditionalFormat Attribute

Applied to properties to highlight cells based on conditions. AllowMultiple — you can apply several to one property:

// Numeric comparisons:
[ConditionalFormat(When = "> 100", BackgroundColor = "#C6EFCE", FontColor = "#006100")]
[ConditionalFormat(When = "< 50", BackgroundColor = "#FFC7CE", FontColor = "#9C0006")]
[ConditionalFormat(When = "= 0", BackgroundColor = "#FFEB9C", Bold = true)]
[ConditionalFormat(When = ">= 75", BackgroundColor = "#DDEBF7")]
[ConditionalFormat(When = "<= 25", FontColor = "#FF0000")]
[ConditionalFormat(When = "<> 100", Italic = true)]
[ConditionalFormat(When = "between 40 60", BackgroundColor = "#E2EFDA")]

// Text operations:
[ConditionalFormat(When = "contains error", BackgroundColor = "#FFC7CE")]
[ConditionalFormat(When = "not contains ok", FontColor = "#FF0000")]

Supported operators: >, <, >=, <=, =, <>, between X Y, contains text, not contains text.

Adds data validation dropdown to cells:

// Static values:
[Dropdown(Values = new[] { "Active", "Inactive", "Pending" })]
public string Status { get; set; }

// From enum:
[Dropdown(EnumType = typeof(DepartmentEnum))]
public string Department { get; set; }
BooleanFormat Attribute

Displays boolean values as custom text:

[BooleanFormat(TrueValue = "Yes", FalseValue = "No")]
public bool IsActive { get; set; }

[BooleanFormat(TrueValue = "Enabled", FalseValue = "Disabled")]
public bool IsEnabled { get; set; }
Formula Attribute

Adds an Excel formula to cells (the {row} placeholder is replaced with the current row number):

[Formula(Expression = "=D{row}*E{row}", Format = "#,##0.00")]
public string Total { get; set; }  // Must be string type
SummaryRow Attribute

Adds a summary row at the bottom of the data. Applied to the class, not a property. AllowMultiple:

[Sheet(Name = "Sales")]
[SummaryRow(Label = "Total:", LabelColumn = "ProductName", Function = SummaryFunction.Sum, Columns = new[] { "Amount", "Quantity" })]
public class SalesRow
{
    public string ProductName { get; set; }
    public decimal Amount { get; set; }
    public int Quantity { get; set; }
}
Property Description
Label Text to display in the summary row.
LabelColumn Property name where the label appears.
Function Sum, Average, Count, Min, or Max.
Columns Property names to apply the function to.
MergeWith Attribute

Merges this column's cells with another column when values match:

[MergeWith(PropertyName = "Category")]
public string SubCategory { get; set; }
IgnoreColumn Attribute

Excludes a property from the spreadsheet:

[IgnoreColumn]
public string InternalNotes { get; set; }
SearchableIndex Attribute

Creates a named range in Excel for the column data (useful for VLOOKUP):

[SearchableIndex]
public string EmployeeId { get; set; }

Spreadsheet Enums

public enum SummaryFunction { Sum, Average, Count, Min, Max }
public enum HorizontalAlignment { Left, Center, Right }
public enum SheetProtectionMode { None, Password, Sealed }

Excel Export

// Export to byte array:
byte[] bytes = spreadsheet.Export(employees);

// Export to stream:
Stream stream = spreadsheet.ExportAsStream(employees);

// Export to SpreadsheetFile (includes filename, content type, length):
SpreadsheetFile file = spreadsheet.ToFile(employees, "report.xlsx");

CSV Export

// Export to byte array:
byte[] csvBytes = spreadsheet.ExportCsv(employees);

// Export to stream:
Stream csvStream = spreadsheet.ExportCsvAsStream(employees);

// Export as string:
string csvString = spreadsheet.ExportCsvAsString(employees);

// Export to SpreadsheetFile:
SpreadsheetFile csvFile = spreadsheet.ToCsvFile(employees, "report.csv");

CSV export respects column order, skips hidden/ignored columns, applies BooleanFormat and DateTime format, and is RFC 4180 compliant.

Multi-Sheet Workbook

IWorkbookBuilder builder = spreadsheet.CreateWorkbook();

// Add typed sheets:
builder.AddSheet(employees);                             // Sheet name from [Sheet] attribute
builder.AddSheet(employees, "Custom Sheet Name");        // Override sheet name
builder.AddSheet(employees, new SheetOverrides { ... }); // With overrides

// Add DataTable sheet:
builder.AddSheet("Raw Data", myDataTable);
builder.AddSheet("Raw Data", myDataTable, new DataTableSheetOptions { ... });

// Set workbook metadata:
builder.WithMetadata(m =>
{
    m.Author = "My App";
    m.Title = "Monthly Report";
    m.Subject = "Sales Data";
    m.Company = "Acme Corp";
    m.Comments = "Auto-generated";
    m.Protection = SheetProtectionMode.Password;
    m.Password = "secret";
    m.LockStructure = true;
    m.LockWindows = false;
});

// Build:
byte[] bytes = builder.Build();
Stream stream = builder.BuildAsStream();
SpreadsheetFile file = builder.BuildAsFile("report.xlsx");

Template Engine

Fill existing .xlsx templates with data:

// From file path:
ITemplateBuilder template = spreadsheet.FromTemplate("templates/invoice.xlsx");

// From stream or byte array:
ITemplateBuilder template = spreadsheet.FromTemplate(stream);
ITemplateBuilder template = spreadsheet.FromTemplate(bytes);

// Bind data to placeholders (replaces {{PropertyName}} in cells):
template.Bind(new
{
    CompanyName = "Acme Corp",
    InvoiceDate = DateTime.Now,
    TotalAmount = 1234.56m
});

// Bind typed data to a named range:
template.BindTable("ItemsRange", invoiceItems);

// Apply protection:
template.WithProtection(SheetProtectionMode.Password, "secret");
template.WithWorkbookProtection(SheetProtectionMode.Sealed);

// Build:
byte[] bytes = template.Build();
SpreadsheetFile file = template.BuildAsFile("invoice.xlsx");

Import

// Import from byte array:
List<EmployeeRow> employees = spreadsheet.Import<EmployeeRow>(bytes);

// Import with validation (catches row-level errors):
ImportResult<EmployeeRow> result = spreadsheet.ImportWithValidation<EmployeeRow>(bytes);

if (result.HasErrors)
{
    foreach (var error in result.Errors)
    {
        Console.WriteLine($"Row {error.Row}: {error.Message}");
    }
}

var validData = result.Data; // Successfully imported rows
ImportResult and ImportError
public sealed class ImportResult<T> where T : class
{
    public List<T> Data { get; }               // Successfully imported rows
    public List<ImportError> Errors { get; }   // Rows that failed
    public bool HasErrors { get; }             // true if any errors
    public bool IsValid { get; }               // true if no errors
}

public sealed class ImportError
{
    public int Row { get; init; }              // 1-based row number
    public int Column { get; init; }           // 1-based column number
    public string? PropertyName { get; init; }
    public string? Message { get; init; }
    public string? RawValue { get; init; }
}

Sheet Overrides

Override attribute settings at runtime without changing the class:

var overrides = new SheetOverrides
{
    SheetName = "Custom Name",         // Override [Sheet] Name
    RightToLeft = true,                // Override RTL setting
    AutoFilter = false,                // Override auto-filter
    FreezeHeader = false,              // Override freeze header
    HiddenColumns = { "Salary" },      // Hide specific columns by property name
    ColumnNameOverrides = new Dictionary<string, string>
    {
        ["Name"] = "Employee Name",    // Override column headers
        ["Department"] = "Dept."
    },
    Protection = SheetProtectionMode.Password,
    Password = "secret"
};

byte[] bytes = spreadsheet.Export(employees, overrides);

Protection

Three protection modes:

Mode Description
None No protection (default).
Password Protected with a user-supplied password. Users can unprotect with the password.
Sealed Protected with a random 48-byte password that nobody knows. Effectively read-only.
// Via attribute:
[Sheet(Protection = SheetProtectionMode.Password, Password = "mypass",
       AllowSort = true, AllowFilter = true)]
public class MyRow { ... }

// Via overrides:
var overrides = new SheetOverrides
{
    Protection = SheetProtectionMode.Sealed
};

// Workbook-level protection:
builder.WithMetadata(m =>
{
    m.Protection = SheetProtectionMode.Password;
    m.Password = "workbook-pass";
    m.LockStructure = true;
    m.LockWindows = false;
});

DataTable Support

Export System.Data.DataTable directly:

var dt = new DataTable();
dt.Columns.Add("Name", typeof(string));
dt.Columns.Add("Age", typeof(int));
dt.Rows.Add("John", 30);

var builder = spreadsheet.CreateWorkbook();
builder.AddSheet("People", dt, new DataTableSheetOptions
{
    AutoFilter = true,
    FreezeHeader = true,
    RightToLeft = false,
    HeaderStyle = new CellStyleInfo { Bold = true, FontSize = 14 },
    ColumnFormats = new Dictionary<string, string> { ["Age"] = "0" },
    ColumnWidths = new Dictionary<string, double> { ["Name"] = 30 },
    HiddenColumns = new HashSet<string>(),
    Protection = SheetProtectionMode.None
});

Streaming

Export directly to a destination stream (avoids buffering in memory):

// Excel streaming:
await spreadsheet.ExportToStreamAsync(employees, outputStream);

// CSV streaming:
await spreadsheet.ExportCsvToStreamAsync(employees, outputStream);

// Workbook streaming:
await builder.BuildToStreamAsync(outputStream);

SpreadsheetFile

A disposable wrapper around the exported data:

public sealed class SpreadsheetFile : IDisposable, IAsyncDisposable
{
    public string FileName { get; }                  // e.g., "report.xlsx"
    public string ContentType { get; }               // e.g., "application/vnd.openxmlformats..."
    public long Length { get; }                       // File size in bytes

    public byte[] GetBytes();                         // Get as byte array
    public Stream GetStream();                        // Get as readable stream
    public Task CopyToAsync(Stream destination);      // Copy to another stream
    public void CopyTo(Stream destination);           // Synchronous copy
}

ISpreadsheet API Reference

public interface ISpreadsheet
{
    // Single-sheet Excel export:
    byte[] Export<T>(IEnumerable<T> data, SheetOverrides? overrides = null) where T : class;
    Stream ExportAsStream<T>(IEnumerable<T> data, SheetOverrides? overrides = null) where T : class;
    Task ExportToStreamAsync<T>(IEnumerable<T> data, Stream destination, SheetOverrides? overrides = null) where T : class;

    // CSV export:
    byte[] ExportCsv<T>(IEnumerable<T> data) where T : class;
    Stream ExportCsvAsStream<T>(IEnumerable<T> data) where T : class;
    string ExportCsvAsString<T>(IEnumerable<T> data) where T : class;
    Task ExportCsvToStreamAsync<T>(IEnumerable<T> data, Stream destination) where T : class;

    // File results:
    SpreadsheetFile ToFile<T>(IEnumerable<T> data, string? fileName = null, SheetOverrides? overrides = null) where T : class;
    SpreadsheetFile ToCsvFile<T>(IEnumerable<T> data, string? fileName = null) where T : class;

    // Multi-sheet workbook:
    IWorkbookBuilder CreateWorkbook();

    // Template engine:
    ITemplateBuilder FromTemplate(string path);
    ITemplateBuilder FromTemplate(Stream stream);
    ITemplateBuilder FromTemplate(byte[] bytes);

    // Import:
    List<T> Import<T>(byte[] data) where T : class, new();
    ImportResult<T> ImportWithValidation<T>(byte[] data) where T : class, new();
}

IWorkbookBuilder API Reference

public interface IWorkbookBuilder
{
    IWorkbookBuilder AddSheet<T>(IEnumerable<T> data) where T : class;
    IWorkbookBuilder AddSheet<T>(IEnumerable<T> data, SheetOverrides overrides) where T : class;
    IWorkbookBuilder AddSheet<T>(string sheetName, IEnumerable<T> data) where T : class;
    IWorkbookBuilder AddSheet(string sheetName, DataTable dataTable);
    IWorkbookBuilder AddSheet(string sheetName, DataTable dataTable, DataTableSheetOptions options);
    IWorkbookBuilder WithMetadata(Action<WorkbookMetadata> configure);

    byte[] Build();
    Stream BuildAsStream();
    SpreadsheetFile BuildAsFile(string? fileName = null);
    Task BuildToStreamAsync(Stream destination);
}

ITemplateBuilder API Reference

public interface ITemplateBuilder
{
    ITemplateBuilder Bind(object data);
    ITemplateBuilder BindTable<T>(string rangeName, IEnumerable<T> data) where T : class;
    ITemplateBuilder WithProtection(SheetProtectionMode mode = SheetProtectionMode.Password, string? password = null);
    ITemplateBuilder WithWorkbookProtection(SheetProtectionMode mode = SheetProtectionMode.Password, string? password = null);

    byte[] Build();
    Stream BuildAsStream();
    SpreadsheetFile BuildAsFile(string? fileName = null);
}

Spreadsheet Source Generator Diagnostics

The spreadsheet source generator validates your attributes at compile time:

ID Severity Description
SS001 Error Class has no public properties.
SS002 Error Class has no parameterless constructor.
SS003 Error ConditionalFormat.When expression couldn't be parsed.
SS004 Error [Formula] can only be applied to string properties.
SS005 Error [BooleanFormat] can only be applied to bool properties.
SS006 Error [Dropdown(EnumType)] — type is not an enum.
SS007 Error [SummaryRow] references a column that doesn't exist.
SS008 Warning Duplicate Order values on different columns.
SS009 Error [MergeWith] references a property that doesn't exist.
SS010 Warning Property has both [Column] and [IgnoreColumn].
SS011 Warning Protection = Password but no password provided.
SS012 Info Password set but Protection = None.
SS013 Info Protection = Sealed with password (password will be ignored).

Spreadsheet Configuration

{
  "Spreadsheet": {
    "Enabled": true,
    "Options": {
      "LicenseType": "NonCommercial",
      "LicenseKey": null,
      "DefaultDateFormat": "yyyy-MM-dd",
      "DefaultNumberFormat": "#,##0.00",
      "DefaultFont": {
        "Name": "Calibri",
        "Size": 11
      },
      "HeaderStyle": {
        "Bold": true,
        "FontSize": 12,
        "BackgroundColor": "#2B5797",
        "FontColor": "#FFFFFF",
        "FreezeHeader": true
      },
      "Csv": {
        "Delimiter": ",",
        "Encoding": "UTF-8",
        "IncludeHeader": true,
        "DateFormat": "yyyy-MM-dd",
        "NullValue": ""
      },
      "RightToLeft": false,
      "MaxExportRows": 100000,
      "TemplatePath": "templates",
      "AutoFitColumns": true,
      "AutoFitMaxWidth": 60,
      "DefaultColumnWidth": 15
    }
  }
}
SpreadsheetOptions Properties
Property Type Default Description
LicenseType string "NonCommercial" "NonCommercial" or "Commercial".
LicenseKey string? null EPPlus commercial license key.
DefaultDateFormat string "yyyy-MM-dd" Date format for date columns without [Column(Format)].
DefaultNumberFormat string "#,##0.00" Number format for decimal/float columns.
DefaultFont.Name string "Calibri" Default font family.
DefaultFont.Size float 11 Default font size.
HeaderStyle.Bold bool true Bold headers by default.
HeaderStyle.FontSize float 12 Header font size.
HeaderStyle.BackgroundColor string "#2B5797" Header background color.
HeaderStyle.FontColor string "#FFFFFF" Header font color.
HeaderStyle.FreezeHeader bool true Freeze header row by default.
Csv.Delimiter string "," CSV column delimiter.
Csv.Encoding string "UTF-8" CSV file encoding.
Csv.IncludeHeader bool true Include header row in CSV.
Csv.DateFormat string "yyyy-MM-dd" Date format in CSV output.
Csv.NullValue string "" Value to write for null cells.
RightToLeft bool false Global RTL default.
MaxExportRows int 100000 Maximum rows per export.
TemplatePath string "templates" Base path for template files.
AutoFitColumns bool true Auto-fit column widths.
AutoFitMaxWidth double 60 Maximum auto-fit width.
DefaultColumnWidth double 15 Default column width when not auto-fitting.

Image Processing

Substratum includes IImageService for image resizing, WebP compression, and BlurHash generation:

public interface IImageService
{
    Task<ImageResult> ProcessAsync(Stream input, ImageProcessingOptions? options = null,
        CancellationToken ct = default);
    Task<string> BlurHashAsync(Stream input, int componentsX = 4, int componentsY = 3,
        CancellationToken ct = default);
}

IImageService is registered as a singleton automatically — just inject it.

ImageProcessingOptions
public sealed class ImageProcessingOptions
{
    public int MaxWidth { get; init; } = 512;           // Max width in pixels
    public int MaxHeight { get; init; } = 512;          // Max height in pixels
    public int Quality { get; init; } = 70;             // WebP quality (0-100)
    public bool SkipMetadata { get; init; } = true;     // Strip EXIF data
    public bool NearLossless { get; init; } = true;     // Use near-lossless WebP encoding
    public int NearLosslessQuality { get; init; } = 50; // Near-lossless quality
}
ImageResult
public sealed class ImageResult : IDisposable
{
    public MemoryStream Stream { get; init; }    // Processed image data
    public string ContentType { get; init; }     // Always "image/webp"
    public long Length { get; }                  // Byte count
    public int Width { get; init; }              // Final pixel width
    public int Height { get; init; }             // Final pixel height
}

Example:

// Resize and compress to WebP:
using var result = await imageService.ProcessAsync(uploadedFile.OpenReadStream(), new ImageProcessingOptions
{
    MaxWidth = 800,
    MaxHeight = 600,
    Quality = 80
});

await fileStorage.UploadAsync("images/photo.webp", result.Stream, result.ContentType, ct);

// Generate BlurHash placeholder:
string blurHash = await imageService.BlurHashAsync(uploadedFile.OpenReadStream());
// Returns something like: "LEHV6nWB2yk8pyo0adR*.7kCMdnj"

Cloud Storage

MinIO (S3-Compatible)

{
  "Minio": {
    "Enabled": true,
    "Options": {
      "Endpoint": "play.min.io",
      "Region": "us-east-1",
      "Secure": true,
      "AccessKey": "YOUR_ACCESS_KEY",
      "SecretKey": "YOUR_SECRET_KEY"
    }
  }
}

When enabled, the IMinioClient from the official Minio SDK is registered and ready to inject.

AWS S3

{
  "Aws": {
    "S3": {
      "Enabled": true,
      "Options": {
        "Endpoint": null,
        "Region": "us-east-1",
        "ForcePathStyle": false,
        "AccessKey": "YOUR_ACCESS_KEY",
        "SecretKey": "YOUR_SECRET_KEY"
      }
    }
  }
}

When enabled, IAmazonS3 from the AWS SDK is registered and ready to inject.

Azure Blob Storage

{
  "Azure": {
    "BlobStorage": {
      "Enabled": true,
      "Options": {
        "ConnectionString": "YOUR_AZURE_BLOB_STORAGE_CONNECTION_STRING"
      }
    }
  }
}

When enabled, BlobServiceClient from the Azure SDK is registered and ready to inject.

Unified File Storage (IFileStorage)

Substratum provides a unified file storage interface that works with Local filesystem, AWS S3, and Azure Blob Storage:

public enum StorageProvider { Local, S3, AzureBlob }

public interface IFileStorage
{
    // Provider-specific operations:
    Task UploadAsync(StorageProvider provider, string container, string path,
        Stream content, string? contentType = null, CancellationToken ct = default);
    Task<Stream> DownloadAsync(StorageProvider provider, string container, string path,
        CancellationToken ct = default);
    Task DeleteAsync(StorageProvider provider, string container, string path,
        CancellationToken ct = default);
    Task<bool> ExistsAsync(StorageProvider provider, string container, string path,
        CancellationToken ct = default);

    // Default provider operations (uses configured provider & container):
    Task UploadAsync(string path, Stream content, string? contentType = null,
        CancellationToken ct = default);
    Task<Stream> DownloadAsync(string path, CancellationToken ct = default);
    Task DeleteAsync(string path, CancellationToken ct = default);
    Task<bool> ExistsAsync(string path, CancellationToken ct = default);
}

Configure in appsettings.json:

{
  "FileStorage": {
    "Enabled": true,
    "Options": {
      "Provider": "Local",
      "Container": "uploads",
      "MaxFileSizeBytes": 52428800,
      "AllowedExtensions": [".jpg", ".png", ".pdf", ".docx"]
    }
  }
}
FileStorageOptions
Property Type Default Description
Provider StorageProvider Local Default storage provider.
Container string "" Default bucket/container name.
MaxFileSizeBytes long 0 Max upload size (0 = unlimited).
AllowedExtensions string[] [] Allowed file extensions (empty = all).

Example:

// Upload using default provider:
await fileStorage.UploadAsync("documents/report.pdf", fileStream, "application/pdf", ct);

// Upload to specific provider:
await fileStorage.UploadAsync(StorageProvider.S3, "my-bucket", "docs/report.pdf", fileStream, ct: ct);

// Download:
var stream = await fileStorage.DownloadAsync("documents/report.pdf", ct);

// Check existence:
bool exists = await fileStorage.ExistsAsync("documents/report.pdf", ct);

// Delete:
await fileStorage.DeleteAsync("documents/report.pdf", ct);

AWS Secrets Manager

Load secrets from AWS Secrets Manager into your configuration:

{
  "Aws": {
    "SecretsManager": {
      "Enabled": true,
      "Options": {
        "Region": "us-east-1",
        "SecretArns": ["arn:aws:secretsmanager:us-east-1:123:secret:my-secret"],
        "ServiceUrl": null,
        "AccessKey": "YOUR_ACCESS_KEY",
        "SecretKey": "YOUR_SECRET_KEY"
      }
    }
  }
}

When enabled, secrets are loaded during startup and merged into IConfiguration. Secret keys are flattened using : notation (e.g., Authentication:JwtBearer:Options:SecretKey).


Firebase

Cloud Messaging

{
  "Firebase": {
    "Messaging": {
      "Enabled": true,
      "Options": {
        "Credential": "BASE_64_ENCODED_SERVICE_ACCOUNT_JSON"
      }
    }
  }
}

When enabled, the Firebase Admin SDK is initialized and FirebaseMessaging is available for sending push notifications.

App Check

{
  "Firebase": {
    "AppCheck": {
      "Enabled": true,
      "Options": {
        "ProjectId": "YOUR_FIREBASE_PROJECT_ID",
        "ProjectNumber": "YOUR_FIREBASE_PROJECT_NUMBER",
        "EnableEmulator": false,
        "EmulatorTestToken": "TEST_TOKEN_FOR_DEVELOPMENT"
      }
    }
  }
}

Inject IFirebaseAppCheck to verify App Check tokens:

public interface IFirebaseAppCheck
{
    Task<bool> VerifyAppCheckTokenAsync(string token, CancellationToken ct = default);
}

When EnableEmulator = true, the service accepts the EmulatorTestToken for local development.


Infrastructure

CORS

{
  "Cors": {
    "AllowedOrigins": ["https://localhost:3000"],
    "AllowedMethods": ["GET", "POST", "PUT", "DELETE", "PATCH"],
    "AllowedHeaders": ["Content-Type", "Authorization", "X-APP-ID", "X-API-KEY"],
    "AllowCredentials": true,
    "MaxAgeSeconds": 600
  }
}
Property Type Default Description
AllowedOrigins string[] [] Allowed origins.
AllowedMethods string[] [] Allowed HTTP methods.
AllowedHeaders string[] [] Allowed request headers.
AllowCredentials bool true Allow credentials (cookies, auth headers).
MaxAgeSeconds int 600 Preflight cache duration (seconds).

Response Compression

{
  "ResponseCompression": {
    "Enabled": true,
    "Options": {
      "EnableForHttps": true,
      "Providers": ["Brotli", "Gzip"],
      "MimeTypes": ["text/plain", "application/json", "text/html"]
    }
  }
}
Property Type Default Description
EnableForHttps bool true Compress HTTPS responses.
Providers string[] ["Brotli", "Gzip"] Compression algorithms.
MimeTypes string[] [] MIME types to compress (empty = framework defaults).

Enabled by defaultResponseCompression starts enabled without explicit config.

Forwarded Headers

For apps behind a reverse proxy (nginx, load balancer):

{
  "ForwardedHeaders": {
    "Enabled": true,
    "Options": {
      "ForwardedHeaders": ["XForwardedFor", "XForwardedProto"],
      "KnownProxies": [],
      "KnownNetworks": []
    }
  }
}

Request Limits

{
  "RequestLimits": {
    "MaxRequestBodySizeBytes": 52428800,
    "MaxMultipartBodyLengthBytes": 134217728
  }
}
Property Type Default Description
MaxRequestBodySizeBytes long 52428800 Max request body size (50 MB).
MaxMultipartBodyLengthBytes long 134217728 Max multipart upload size (128 MB).

Rate Limiting

{
  "RateLimiting": {
    "Enabled": true,
    "Options": {
      "GlobalPolicy": "Default",
      "RejectionStatusCode": 429,
      "Policies": {
        "Default": {
          "Type": "FixedWindow",
          "PermitLimit": 100,
          "WindowSeconds": 60,
          "QueueLimit": 0
        },
        "Strict": {
          "Type": "SlidingWindow",
          "PermitLimit": 10,
          "WindowSeconds": 60,
          "SegmentsPerWindow": 6,
          "QueueLimit": 0
        }
      }
    }
  }
}
Rate Limiting Policy Types
public enum RateLimitingPolicyType
{
    FixedWindow,      // Fixed time window
    SlidingWindow,    // Sliding window with segments
    TokenBucket,      // Token bucket algorithm
    Concurrency       // Concurrent request limit
}
Policy Options
Property Type Default Applies To Description
Type RateLimitingPolicyType FixedWindow All Algorithm type.
PermitLimit int 100 Fixed, Sliding, Concurrency Max requests allowed.
WindowSeconds int 60 Fixed, Sliding Time window duration.
SegmentsPerWindow int 6 Sliding only Window segments.
TokenLimit int 10 TokenBucket only Max tokens.
ReplenishmentPeriodSeconds int 10 TokenBucket only Token replenishment interval.
TokensPerPeriod int 2 TokenBucket only Tokens added per period.
QueueLimit int 0 All Queued requests limit.
Custom Partition Key

By default, rate limiting partitions by authenticated user ID or IP address. Implement IRateLimitPartitioner for custom partitioning:

public interface IRateLimitPartitioner
{
    string GetPartitionKey(HttpContext httpContext, string policyName);
}

Distributed Cache

{
  "DistributedCache": {
    "Enabled": true,
    "Options": {
      "Provider": "Redis",
      "Redis": {
        "ConnectionString": "localhost:6379",
        "InstanceName": "DC_"
      }
    }
  }
}
Provider Description
Memory In-memory distributed cache (single server).
Redis Redis-backed distributed cache (multi-server).

When enabled, IDistributedCache is available for injection.

Health Checks

{
  "HealthChecks": {
    "Enabled": true,
    "Options": {
      "Path": "/healthz"
    }
  }
}

Access at GET /healthz. Automatically includes DbContext health checks for all registered Entity Framework contexts.

You can add custom health checks via C# configuration:

return await SubstratumApp.RunAsync(args, options =>
{
    options.HealthChecks.Options.HealthChecksBuilder = builder =>
    {
        builder.AddRedis("localhost:6379");
    };
});

Static Files

{
  "StaticFiles": {
    "Enabled": true,
    "Options": {
      "RootPath": "wwwroot",
      "RequestPath": "",
      "ContentTypeMappings": {
        ".custom": "application/x-custom-type"
      }
    }
  }
}

Error Handling

{
  "ErrorHandling": {
    "IncludeExceptionDetails": true
  }
}

When IncludeExceptionDetails is true, exception stack traces and messages are included in error responses. Set to false in production!

Logging (Serilog)

Substratum uses Serilog for structured logging, configured entirely through appsettings.json:

{
  "Serilog": {
    "Using": ["Serilog.Sinks.Console", "Serilog.Enrichers.Sensitive"],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "Microsoft.Hosting.Lifetime": "Information",
        "System": "Warning",
        "Microsoft.EntityFrameworkCore": "Warning"
      }
    },
    "Enrich": [
      "FromLogContext",
      "WithMachineName",
      "WithThreadId",
      {
        "Name": "WithSensitiveDataMasking",
        "Args": {
          "options": {
            "MaskValue": "*****",
            "MaskProperties": [
              { "Name": "Password" },
              { "Name": "HashPassword" }
            ]
          }
        }
      }
    ],
    "Properties": { "Application": "MyApp" },
    "WriteTo": [
      { "Name": "Console" }
    ]
  }
}

Sensitive data masking is built-in — add property names to MaskProperties and they'll be automatically masked in logs.


Email

SMTP email sending via MailKit. Supports HTML/plain text, attachments, CC/BCC, reply-to, and simple template variables.

{
  "Email": {
    "Enabled": true,
    "Options": {
      "Host": "smtp.example.com",
      "Port": 587,
      "Username": "your-username",
      "Password": "your-password",
      "FromAddress": "noreply@example.com",
      "FromName": "My App",
      "UseSsl": true,
      "TimeoutMs": 30000
    }
  }
}

Usage:

public class SendWelcomeEndpoint : BaseEndpoint<SendWelcomeRequest, Unit>
{
    public required IEmailService EmailService { get; init; }

    public override void Configure()
    {
        Post("/emails/welcome");
    }

    public override async Task HandleAsync(SendWelcomeRequest req, CancellationToken ct)
    {
        await EmailService.SendAsync(new EmailMessage
        {
            To = [req.Email],
            Subject = "Welcome!",
            Body = "<h1>Welcome to our app!</h1>",
            IsHtml = true
        }, ct);

        await SendOkAsync(ct);
    }
}

Templated emails with {{placeholder}} replacement:

await EmailService.SendTemplatedAsync(
    templateBody: "<p>Hello {{Name}}, your order #{{OrderId}} is confirmed.</p>",
    replacements: new Dictionary<string, string>
    {
        ["Name"] = "Ahmed",
        ["OrderId"] = "12345"
    },
    message: new EmailMessage
    {
        To = ["ahmed@example.com"],
        Subject = "Order Confirmation",
        IsHtml = true
    }, ct);
Property Type Default Description
Host string SMTP server hostname.
Port int 587 SMTP port.
Username string "" SMTP username (skip auth if empty).
Password string "" SMTP password.
FromAddress string Default sender email.
FromName string Default sender display name.
UseSsl bool true Use SSL/TLS.
TimeoutMs int 30000 SMTP connection timeout in ms.

Background Jobs

Fire-and-forget job queue and recurring interval jobs using System.Threading.Channels and BackgroundService. No external dependencies.

{
  "BackgroundJobs": {
    "Enabled": true,
    "Options": {
      "MaxConcurrency": 1,
      "QueueCapacity": 100
    }
  }
}

Fire-and-Forget Jobs

Inject IBackgroundJobService and enqueue work items:

public class ProcessOrderEndpoint : BaseEndpoint<ProcessOrderRequest, Unit>
{
    public required IBackgroundJobService Jobs { get; init; }

    public override async Task HandleAsync(ProcessOrderRequest req, CancellationToken ct)
    {
        await Jobs.EnqueueAsync(async (sp, ct) =>
        {
            var db = sp.GetRequiredService<AppDbContext>();
            // ... long-running work
        }, ct);

        await SendOkAsync(ct);
    }
}

Recurring Jobs

Implement IRecurringJob — the source generator auto-discovers and registers all implementations:

public class CleanupExpiredTokensJob : IRecurringJob
{
    public string Name => "CleanupExpiredTokens";
    public TimeSpan Interval => TimeSpan.FromHours(1);
    public bool RunOnStartup => false;

    public async Task ExecuteAsync(CancellationToken ct)
    {
        // cleanup logic
    }
}
Property Type Default Description
MaxConcurrency int 1 Max parallel job executions.
QueueCapacity int 100 Max queued jobs before backpressure.

Audit Logging

Automatic entity change tracking via an EF Core SaveChangesInterceptor. Captures create, update, and delete operations and stores them in the same transaction as your business data.

{
  "AuditLogging": {
    "Enabled": true,
    "Options": {
      "IncludePropertyChanges": true
    }
  }
}

Quick Start

  1. Implement IAuditableDbContext on your DbContext:
public class AppDbContext : DbContext, IAuditableDbContext
{
    public DbSet<AuditLog> AuditLogs => Set<AuditLog>();

    // ... your other DbSets
}

That's it. Every SaveChangesAsync() call now automatically captures entity changes into the AuditLogs table in the same transaction — no separate service, no extra save call.

How It Works

The interceptor runs in SavingChangesAsync (before the save). It walks the ChangeTracker, creates AuditLog entities for each Added/Modified/Deleted entity, and adds them to the AuditLogs DbSet. EF Core then persists business entities and audit logs together in a single transaction.

  • AuditLog entities are never audited themselves (skipped automatically)
  • UserId is populated from ICurrentUser when available
  • Changes is a JSON-serialized list of property changes (old/new values)

AuditLog Entity

Property Type Description
Id Guid Auto-generated primary key.
EntityType string Full CLR type name of the entity.
EntityId string Primary key value(s), comma-separated for composites.
Action AuditAction Create, Update, or Delete.
Timestamp DateTimeOffset When the change occurred.
UserId Guid? Authenticated user ID (from ICurrentUser).
Changes string? JSON-serialized List<PropertyChange> (when enabled).

Custom Storage (Advanced)

If you need to store audit logs externally (separate database, message queue, etc.), implement IAuditStore instead. The source generator auto-discovers it. This path is used for DbContexts that don't implement IAuditableDbContext:

public class ExternalAuditStore : IAuditStore
{
    public async Task StoreAsync(IReadOnlyList<AuditEntry> entries, CancellationToken ct)
    {
        // send to external system
    }
}
Option Type Default Description
IncludePropertyChanges bool true Track individual property changes.

Encryption

AES-256-GCM encryption with key rotation support. Outputs nonce(12) + ciphertext + tag(16) format.

{
  "Encryption": {
    "Enabled": true,
    "Options": {
      "Key": "BASE64_ENCODED_32_BYTE_KEY",
      "SecondaryKey": null
    }
  }
}

Generate a key: Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))

Usage:

public class EncryptEndpoint : BaseEndpoint<EncryptRequest, EncryptResponse>
{
    public required IEncryptionService Encryption { get; init; }

    public override async Task HandleAsync(EncryptRequest req, CancellationToken ct)
    {
        var encrypted = Encryption.Encrypt(req.Plaintext);
        var decrypted = Encryption.Decrypt(encrypted);

        await SendOkAsync(new EncryptResponse { Ciphertext = encrypted }, ct);
    }
}

Key rotation: Encrypt always uses the primary key. Decrypt tries the primary key first, then falls back to SecondaryKey. To rotate: move the current key to SecondaryKey, set a new primary Key, then re-encrypt data at your convenience.

Property Type Default Description
Key string Base64-encoded 32-byte (256-bit) primary key.
SecondaryKey string? null Base64-encoded 32-byte fallback key for rotation.

Webhooks

Event-driven HTTP POST delivery with HMAC-SHA256 signatures, retry with exponential backoff, and optional delivery logging.

{
  "Webhooks": {
    "Enabled": true,
    "Options": {
      "Secret": "YOUR_WEBHOOK_SECRET_MIN_16_CHARS",
      "MaxRetries": 3,
      "RetryDelaySeconds": 5,
      "TimeoutSeconds": 30,
      "QueueCapacity": 100
    }
  }
}

Implement IWebhookStore (required) — the source generator auto-discovers it:

public class WebhookStore : IWebhookStore
{
    private readonly AppDbContext _db;

    public WebhookStore(AppDbContext db) => _db = db;

    public async Task<IReadOnlyList<WebhookSubscription>> GetSubscriptionsAsync(
        string eventType, CancellationToken ct)
    {
        return await _db.WebhookSubscriptions
            .Where(s => s.IsActive && s.EventTypes.Contains(eventType))
            .ToListAsync(ct);
    }
}

Optionally implement IWebhookDeliveryLog for delivery tracking:

public class WebhookDeliveryLog : IWebhookDeliveryLog
{
    public async Task LogAsync(WebhookDeliveryResult result, CancellationToken ct)
    {
        // persist delivery result
    }
}

Publish events:

public class CreateOrderEndpoint : BaseEndpoint<CreateOrderRequest, OrderResponse>
{
    public required IWebhookService Webhooks { get; init; }

    public override async Task HandleAsync(CreateOrderRequest req, CancellationToken ct)
    {
        // ... create order ...
        await Webhooks.PublishAsync("order.created", new { OrderId = order.Id, Total = order.Total }, ct);
        await SendOkAsync(response, ct);
    }
}

Delivery details:

  • HTTP POST with JSON body
  • X-Webhook-Event header with event type
  • X-Webhook-Signature: sha256={hmac} header for payload verification
  • Exponential backoff retry: delay * 2^(attempt-1)
Property Type Default Description
Secret string HMAC-SHA256 signing secret (min 16 chars).
MaxRetries int 3 Max delivery retry attempts (0-10).
RetryDelaySeconds int 5 Base retry delay in seconds.
TimeoutSeconds int 30 HTTP request timeout.
QueueCapacity int 100 Max queued webhook events.

Substratum.Generator

The Substratum.Generator package contains 8 incremental source generators that eliminate runtime reflection and automate boilerplate.

How Source Generators Work

The generators target netstandard2.0 and use Scriban templating to generate C# code at compile time. All generated code is added to the compilation automatically.

Technical details:

  • Each generator uses IIncrementalGenerator (Roslyn incremental pipeline)
  • Templates are embedded resources in .sbn-cs format
  • Shared helpers in Core/RoslynHelpers.cs and Core/TemplateRenderer.cs
  • All type metadata is computed at compile time

App Generator

What it generates: A [ModuleInitializer] that sets SubstratumApp.Factory — the delegate that boots the entire application pipeline.

Why: This is how SubstratumApp.RunAsync(args) works with just one line of code. The generator discovers your project, generates the bootstrap code, and sets the Factory at module initialization.

Diagnostics:

ID Severity Description
SA001 Error Template file could not be loaded.

FastEndpoints Generators

Three generators work together for FastEndpoints integration:

1. DiscoveredTypes Generator — finds all endpoint, validator, mapper, summary, and event handler types:

ID Severity Description
FE001 Error Template file could not be loaded.

2. ServiceRegistration Generator — auto-registers services marked with [RegisterService] or discovered patterns:

ID Severity Description
SR001 Error Template file could not be loaded.

3. Reflection Generator — generates a pre-computed ReflectionCache to avoid runtime reflection:

ID Severity Description
RE001 Error Template file could not be loaded.

Permissions Generator

Discovers all PermissionDefinition static fields and generates:

  • An IPermissionRegistry implementation
  • Static Parse(string code) and TryParse(string code, out PermissionDefinition?) methods
  • A Definitions() method returning all permissions
ID Severity Description
PM001 Error Template file could not be loaded.
PM002 Error Template parse error.
PM003 Error Code generation error.

Localization Generator

Discovers .resx files, extracts supported cultures, and generates a [ModuleInitializer] that auto-configures:

  • ResourceSource type
  • DefaultCulture
  • SupportedCultures
ID Severity Description
LZ001 Error Template file could not be loaded.
LZ002 Error Template parse error.
LZ003 Error Code generation error.

EndpointSummary Generator

Discovers SubstratumEndpointSummary subclasses and generates code that passes the IStringLocalizer to Configure():

ID Severity Description
ES001 Error Template file could not be loaded.
ES002 Error Template parse error.
ES003 Error Code generation error.

DocGroup Generator

Discovers DocGroupDefinition static fields and generates an IDocGroupRegistry implementation:

ID Severity Description
DG001 Error Template file could not be loaded.
DG002 Error Template parse error.
DG003 Error Code generation error.

Spreadsheet Generator

Discovers classes with [Sheet] and generates:

  • ISheetMetadata<T> implementations with all column/style/validation metadata
  • A [ModuleInitializer] registry that registers all metadata into SheetMetadataStore
  • Compile-time type mapping, formula parsing, conditional format parsing

See Spreadsheet Source Generator Diagnostics for the full diagnostics table.

Generator Diagnostics Reference

All generator diagnostics follow the pattern: {prefix}{number} where prefix is SA (App), FE (FastEndpoints), SR (ServiceRegistration), RE (Reflection), PM (Permissions), LZ (Localization), ES (EndpointSummary), DG (DocGroup), SS (Spreadsheet).


CLI Tools (dotnet-sub)

Install the CLI tool:

dotnet tool install --global Substratum.Tools

Create Web App

Scaffold a complete new project:

dotnet-sub new webapp MyApp

This creates a full project structure with:

  • Program.cs (single-line bootstrap)
  • appsettings.json (full configuration)
  • Security/ (permissions, session validator, access key validator)
  • Data/ (DbContext, initializer, entity configurations)
  • Domain/ (entities)
  • Features/ (sample endpoint)
  • Resources/ (localization files)

Create Endpoint

Scaffold a new endpoint with all supporting files:

# Normal endpoint:
dotnet-sub new endpoint Users/ListUsers --method GET --route "/api/v1/users" --permission "Users_ListUsers"

# Paginated endpoint:
dotnet-sub new endpoint Users/ListUsers --method GET --route "/api/v1/users" --permission "Users_ListUsers" --paginated

This creates 6 files:

  • EndpointNameEndpoint.cs
  • EndpointNameRequest.cs
  • EndpointNameResponse.cs
  • EndpointNameRequestValidator.cs
  • EndpointNameSerializerContext.cs
  • EndpointNameSummary.cs

Create Entity

Scaffold a new entity:

dotnet-sub new entity Product

Creates an entity class extending BaseEntity<Guid> and an EF Core configuration class.

Database Migrations

# Add a migration:
dotnet-sub migrations add InitialCreate

# Add to specific context:
dotnet-sub migrations add AddProducts --context AnalyticsDbContext

Database Update

# Update to latest migration:
dotnet-sub database update

# Update specific context:
dotnet-sub database update --context AnalyticsDbContext

Generate SQL

# Generate SQL script for all migrations:
dotnet-sub database sql

# Generate from specific migration:
dotnet-sub database sql --from InitialCreate --to AddProducts

All Contracts and Interfaces

Here is a complete list of every public interface and class that Substratum provides for you to implement or use:

Interfaces You Implement

Interface Required? Description
ISessionValidator Optional Validate session on every authenticated request.
IPermissionHydrator Optional Load user permissions into claims.
IBasicAuthValidator Required when Basic Auth enabled Validate Basic auth credentials.
IAccessKeyValidator Required when Access Key enabled Validate API key.
IRefreshTokenStore Optional Store/validate/revoke refresh tokens.
IAppResolver Optional Validate app IDs for multi-app auth.
IRateLimitPartitioner Optional Custom rate limit partition key.
IRecurringJob Optional (multiple) Recurring background job (auto-discovered).
IAuditableDbContext Recommended when Audit Logging enabled Add DbSet<AuditLog> to your DbContext.
IAuditStore Optional (advanced) Custom external audit storage.
IWebhookStore Required when Webhooks enabled Provide webhook subscriptions.
IWebhookDeliveryLog Optional Log webhook delivery results.
IDbContextInitializer<T> Optional Seed database on startup.
IDbContextInitializer Optional Non-generic database seeder.
IPermissionRegistry Auto-generated Permission registry (generated by source generator).
IDocGroupRegistry Auto-generated Document group registry (generated by source generator).

Interfaces You Inject (Services)

Interface Registered Description
IJwtBearer When JWT enabled Create/refresh JWT tokens.
ICookieAuth When Cookie enabled Sign in/out with cookies.
ICurrentUser Always (scoped) Access authenticated user info (UserId, AppId, Permissions).
IPasswordHasher Always (singleton) Hash and verify passwords.
ITotpProvider Always (singleton) TOTP 2FA operations.
IImageService Always (singleton) Image processing + BlurHash.
ISpreadsheet When Spreadsheet enabled Excel/CSV export/import.
IFileStorage When FileStorage enabled Unified file storage.
IEmailService When Email enabled Send SMTP emails.
IBackgroundJobService When BackgroundJobs enabled Enqueue fire-and-forget jobs.
IEncryptionService When Encryption enabled AES-256-GCM encrypt/decrypt.
IWebhookService When Webhooks enabled Publish webhook events.
IFirebaseAppCheck When AppCheck enabled Verify Firebase App Check tokens.

Base Classes You Extend

Class Description
BaseEndpoint<TRequest, TResponse> Your API endpoints.
BaseEntity<T> Your database entities (with soft delete, timestamps).
SubstratumEndpointSummary Your OpenAPI endpoint descriptions.

Data Types

Class Description
Result<T> Standard API response wrapper.
PaginatedResult<T> Paginated query result with metadata.
Unit Empty response type.
PermissionDefinition Permission definition with code, name, group.
DocGroupDefinition Document group for API organization.
SpreadsheetFile Disposable file wrapper for spreadsheet exports.
ImageResult Disposable wrapper for processed images.
ImportResult<T> Spreadsheet import result with errors.
ImportError Individual import error with row/column info.
EmailMessage Email message with To, Cc, Bcc, Subject, Body, Attachments.
EmailAttachment Email attachment with FileName, Content stream, ContentType.
AuditLog EF entity for audit trail (used with IAuditableDbContext).
AuditEntry Audit DTO for IAuditStore (advanced custom storage).
PropertyChange Individual property change (old/new value).
WebhookSubscription Webhook subscription with URL, event types, active status.
WebhookDeliveryResult Webhook delivery result with status, attempt info.

Full Configuration Reference

Below is the complete appsettings.json with every configurable option and its default value:

{
  "ServerEnvironment": "Production",
  "Serilog": {
    "Using": ["Serilog.Sinks.Console", "Serilog.Enrichers.Sensitive"],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "Microsoft.Hosting.Lifetime": "Information",
        "System": "Warning",
        "Microsoft.EntityFrameworkCore": "Warning"
      }
    },
    "Enrich": [
      "FromLogContext",
      "WithMachineName",
      "WithThreadId",
      {
        "Name": "WithSensitiveDataMasking",
        "Args": {
          "options": {
            "MaskValue": "*****",
            "MaskProperties": [
              { "Name": "Password" },
              { "Name": "HashPassword" }
            ]
          }
        }
      }
    ],
    "Properties": { "Application": "MyApp" },
    "WriteTo": [
      { "Name": "Console" }
    ]
  },
  "Cors": {
    "AllowedOrigins": ["https://localhost:3000"],
    "AllowedMethods": ["GET", "POST", "PUT", "DELETE", "PATCH"],
    "AllowedHeaders": ["Content-Type", "Authorization", "X-APP-ID", "X-API-KEY"],
    "AllowCredentials": true,
    "MaxAgeSeconds": 600
  },
  "Authentication": {
    "JwtBearer": {
      "Enabled": true,
      "Options": {
        "SecretKey": "YOUR_SECRET_KEY_MUST_BE_AT_LEAST_32_CHARACTERS_LONG",
        "Issuer": "http://localhost:5000",
        "Audience": "MyApp",
        "Expiration": "365.00:00:00",
        "RefreshExpiration": "7.00:00:00",
        "ClockSkew": "00:02:00",
        "RequireHttpsMetadata": true
      }
    },
    "Cookie": {
      "Enabled": true,
      "Options": {
        "Scheme": "Cookies",
        "CookieName": ".Substratum.Auth",
        "Expiration": "365.00:00:00",
        "SlidingExpiration": true,
        "Secure": true,
        "HttpOnly": true,
        "SameSite": "Lax",
        "AppIdHeaderName": "X-APP-ID"
      }
    },
    "BasicAuthentication": {
      "Enabled": false,
      "Options": {
        "Realm": "MyApp"
      }
    },
    "AccessKeyAuthentication": {
      "Enabled": false,
      "Options": {
        "Realm": "MyApp",
        "KeyName": "X-API-KEY"
      }
    }
  },
  "EntityFramework": {
    "Default": {
      "Provider": "Npgsql",
      "ConnectionString": "Host=localhost;Database=mydb;Username=postgres;Password=password",
      "CommandTimeoutSeconds": 30,
      "EnableSeeding": true,
      "Logging": {
        "EnableDetailedErrors": true,
        "EnableSensitiveDataLogging": true
      },
      "RetryPolicy": {
        "Enabled": true,
        "Options": {
          "MaxRetryCount": 3,
          "MaxRetryDelaySeconds": 5
        }
      },
      "SecondLevelCache": {
        "Enabled": true,
        "Options": {
          "KeyPrefix": "EF_",
          "Provider": "Memory"
        }
      }
    }
  },
  "ErrorHandling": {
    "IncludeExceptionDetails": true
  },
  "Localization": {
    "DefaultCulture": "en"
  },
  "OpenApi": {
    "Enabled": true,
    "Options": {
      "Servers": [
        {
          "Url": "https://localhost:5000",
          "Description": "Local Development"
        }
      ]
    }
  },
  "StaticFiles": {
    "Enabled": false,
    "Options": {
      "RootPath": "wwwroot",
      "RequestPath": "",
      "ContentTypeMappings": {
        ".custom": "application/x-custom-type"
      }
    }
  },
  "HealthChecks": {
    "Enabled": true,
    "Options": {
      "Path": "/healthz"
    }
  },
  "Minio": {
    "Enabled": false,
    "Options": {
      "Endpoint": "play.min.io",
      "Region": "us-east-1",
      "Secure": true,
      "AccessKey": "YOUR_ACCESS_KEY",
      "SecretKey": "YOUR_SECRET_KEY"
    }
  },
  "Aws": {
    "S3": {
      "Enabled": false,
      "Options": {
        "Endpoint": null,
        "Region": "us-east-1",
        "ForcePathStyle": false,
        "AccessKey": "YOUR_ACCESS_KEY",
        "SecretKey": "YOUR_SECRET_KEY"
      }
    },
    "SecretsManager": {
      "Enabled": false,
      "Options": {
        "Region": "us-east-1",
        "SecretArns": ["YOUR_SECRET_ARN"],
        "ServiceUrl": null,
        "AccessKey": "YOUR_ACCESS_KEY",
        "SecretKey": "YOUR_SECRET_KEY"
      }
    }
  },
  "Azure": {
    "BlobStorage": {
      "Enabled": false,
      "Options": {
        "ConnectionString": "YOUR_CONNECTION_STRING"
      }
    }
  },
  "Firebase": {
    "Messaging": {
      "Enabled": false,
      "Options": {
        "Credential": "BASE_64_ENCODED_SERVICE_ACCOUNT_JSON"
      }
    },
    "AppCheck": {
      "Enabled": false,
      "Options": {
        "ProjectId": "YOUR_FIREBASE_PROJECT_ID",
        "ProjectNumber": "YOUR_FIREBASE_PROJECT_NUMBER",
        "EnableEmulator": false,
        "EmulatorTestToken": "TEST_TOKEN"
      }
    }
  },
  "DistributedCache": {
    "Enabled": true,
    "Options": {
      "Provider": "Redis",
      "Redis": {
        "ConnectionString": "localhost:6379",
        "InstanceName": "DC_"
      }
    }
  },
  "ResponseCompression": {
    "Enabled": true,
    "Options": {
      "EnableForHttps": true,
      "Providers": ["Brotli", "Gzip"],
      "MimeTypes": ["text/plain", "application/json", "text/html"]
    }
  },
  "ForwardedHeaders": {
    "Enabled": false,
    "Options": {
      "ForwardedHeaders": ["XForwardedFor", "XForwardedProto"],
      "KnownProxies": [],
      "KnownNetworks": []
    }
  },
  "RequestLimits": {
    "MaxRequestBodySizeBytes": 52428800,
    "MaxMultipartBodyLengthBytes": 134217728
  },
  "FileStorage": {
    "Enabled": true,
    "Options": {
      "Provider": "Local",
      "Container": "uploads",
      "MaxFileSizeBytes": 52428800,
      "AllowedExtensions": [".jpg", ".png", ".pdf", ".docx"]
    }
  },
  "RateLimiting": {
    "Enabled": false,
    "Options": {
      "GlobalPolicy": "Default",
      "RejectionStatusCode": 429,
      "Policies": {
        "Default": {
          "Type": "FixedWindow",
          "PermitLimit": 100,
          "WindowSeconds": 60,
          "QueueLimit": 0
        }
      }
    }
  },
  "Spreadsheet": {
    "Enabled": false,
    "Options": {
      "LicenseType": "NonCommercial",
      "DefaultDateFormat": "yyyy-MM-dd",
      "DefaultNumberFormat": "#,##0.00",
      "DefaultFont": {
        "Name": "Calibri",
        "Size": 11
      },
      "HeaderStyle": {
        "Bold": true,
        "FontSize": 12,
        "BackgroundColor": "#2B5797",
        "FontColor": "#FFFFFF",
        "FreezeHeader": true
      },
      "Csv": {
        "Delimiter": ",",
        "Encoding": "UTF-8",
        "IncludeHeader": true,
        "DateFormat": "yyyy-MM-dd",
        "NullValue": ""
      },
      "RightToLeft": false,
      "MaxExportRows": 100000,
      "TemplatePath": "templates",
      "AutoFitColumns": true,
      "AutoFitMaxWidth": 60,
      "DefaultColumnWidth": 15
    }
  },
  "Email": {
    "Enabled": false,
    "Options": {
      "Host": "smtp.example.com",
      "Port": 587,
      "Username": "",
      "Password": "",
      "FromAddress": "noreply@example.com",
      "FromName": "My App",
      "UseSsl": true,
      "TimeoutMs": 30000
    }
  },
  "BackgroundJobs": {
    "Enabled": false,
    "Options": {
      "MaxConcurrency": 1,
      "QueueCapacity": 100
    }
  },
  "AuditLogging": {
    "Enabled": false,
    "Options": {
      "IncludePropertyChanges": true
    }
  },
  "Encryption": {
    "Enabled": false,
    "Options": {
      "Key": "BASE64_ENCODED_32_BYTE_KEY",
      "SecondaryKey": null
    }
  },
  "Webhooks": {
    "Enabled": false,
    "Options": {
      "Secret": "YOUR_WEBHOOK_SECRET_MIN_16_CHARS",
      "MaxRetries": 3,
      "RetryDelaySeconds": 5,
      "TimeoutSeconds": 30,
      "QueueCapacity": 100
    }
  }
}

License

Substratum is available under the MIT License. See LICENSE for details.

EPPlus (used internally for spreadsheet functionality) requires its own license — choose "NonCommercial" for open source projects or "Commercial" with a license key for commercial use.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-beta.122 0 2/16/2026
1.0.0-beta.110 30 2/15/2026
1.0.0-beta.107 32 2/15/2026
1.0.0-beta.104 29 2/14/2026
1.0.0-beta.103 24 2/14/2026
1.0.0-beta.102 28 2/14/2026
1.0.0-beta.100 27 2/14/2026
1.0.0-beta.96 24 2/14/2026
1.0.0-beta.94 31 2/14/2026
1.0.0-beta.90 24 2/14/2026
1.0.0-beta.80 32 2/14/2026
1.0.0-beta.71 33 2/13/2026
1.0.0-beta.70 38 2/10/2026
1.0.0-beta.68 37 2/10/2026
1.0.0-beta.67 27 2/10/2026
1.0.0-beta.66 27 2/10/2026
1.0.0-beta.65 33 2/10/2026
1.0.0-beta.64 34 2/10/2026
1.0.0-beta.63 32 2/10/2026
1.0.0-beta.62 29 2/10/2026
Loading failed