Substratum.Generator 1.1.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Substratum.Generator --version 1.1.0
                    
NuGet\Install-Package Substratum.Generator -Version 1.1.0
                    
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.Generator" Version="1.1.0">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Substratum.Generator" Version="1.1.0" />
                    
Directory.Packages.props
<PackageReference Include="Substratum.Generator">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
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.Generator --version 1.1.0
                    
#r "nuget: Substratum.Generator, 1.1.0"
                    
#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.Generator@1.1.0
                    
#: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.Generator&version=1.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Substratum.Generator&version=1.1.0
                    
Install as a Cake Tool

Substratum

Substratum is an opinionated, production-grade application framework built on ASP.NET Core Minimal APIs. It eliminates the boilerplate required to bootstrap a modern web API — authentication, authorization, database, caching, logging, OpenAPI docs, cloud storage, 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, DistributedCache, ResponseCompression, ForwardedHeaders, FileStorage, RateLimiting, 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 7 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, and document groups.


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": { ... },
  "Aws": { ... },
  "Azure": { ... },
  "Firebase": { ... },
  "DistributedCache": { ... },
  "ResponseCompression": { ... },
  "ForwardedHeaders": { ... },
  "RequestLimits": { ... },
  "FileStorage": { ... },
  "RateLimiting": { ... }
}

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 ASP.NET Core Minimal APIs:

public abstract class BaseEndpoint<TRequest, 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, maps them as Minimal API routes, 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": {
    "ApiKeyAuthentication": {
      "Enabled": true,
      "Options": {
        "Realm": "MyApp",
        "KeyName": "X-API-KEY"
      }
    }
  }
}

You must implement IApiKeyValidator:

public interface IApiKeyValidator
{
    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 ApiKey = "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 IApiKeyValidator 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
    MySql         // MySQL / MariaDB
}

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"
  }
}

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

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.


Substratum.Generator

The Substratum.Generator package contains 7 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.

Endpoint Generators

Three generators work together for endpoint discovery and registration:

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.

Generator Diagnostics Reference

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


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.
IApiKeyValidator Required when API 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.
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.
IFileStorage When FileStorage enabled Unified file storage.
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. | | ImageResult | Disposable wrapper for processed images. |

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"
      }
    },
    "ApiKeyAuthentication": {
      "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"
    }
  },
  "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
        }
      }
    }
  }
}

License

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

There are no supported framework assets in this 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.1.1 43 3/24/2026
1.1.0 42 3/24/2026
1.0.8 43 3/24/2026
1.0.6 53 3/23/2026
1.0.5 55 3/23/2026
1.0.4 53 3/23/2026
1.0.3 53 3/22/2026
1.0.2 54 3/22/2026
1.0.1 53 3/22/2026
1.0.0 53 3/21/2026
1.0.0-beta.154 47 2/23/2026
1.0.0-beta.153 40 2/23/2026
1.0.0-beta.152 39 2/23/2026
1.0.0-beta.151 44 2/23/2026
1.0.0-beta.150 43 2/22/2026
1.0.0-beta.149 41 2/22/2026
1.0.0-beta.148 45 2/21/2026
1.0.0-beta.147 44 2/21/2026
1.0.0-beta.146 45 2/18/2026
1.0.0-beta.145 40 2/18/2026
Loading failed