Substratum 2.15.0

dotnet add package Substratum --version 2.15.0
                    
NuGet\Install-Package Substratum -Version 2.15.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" Version="2.15.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Substratum" Version="2.15.0" />
                    
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 2.15.0
                    
#r "nuget: Substratum, 2.15.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@2.15.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&version=2.15.0
                    
Install as a Cake Addin
#tool nuget:?package=Substratum&version=2.15.0
                    
Install as a Cake Tool

Substratum

Substratum is an opinionated, production-grade application framework built on ASP.NET Core Minimal APIs. Authentication, authorization, database, caching, logging, OpenAPI docs, cloud storage, push notifications, and real-time events are all pre-wired and configured via appsettings.json.

Write your business logic. Substratum handles the rest.


Table of Contents


Packages

Package NuGet Description
Substratum NuGet Runtime library
Substratum.Generator NuGet Source generators — zero reflection, AOT-compatible
Substratum.Tools NuGet CLI tool (dotnet-sub) — scaffolding, migrations

Add to your .csproj:

<PackageReference Include="Substratum" Version="2.15.0" />
<PackageReference Include="Substratum.Generator" Version="2.15.0"
    OutputItemType="Analyzer" ReferenceOutputAssembly="false" />

Install the CLI 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, authentication, permissions, database, entities, and a sample endpoint.

2. Or add to an existing project

Your entire Program.cs:

return await SubstratumApp.RunAsync(args);

One line. The source generators wire everything else automatically.

Need to register additional services? Use the configure callback:

return await SubstratumApp.RunAsync(args, options =>
{
    options.Services.AddSingleton<IMyService, MyService>();
});

3. Configure via 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

public class GetUsersEndpoint : Endpoint<GetUsersRequest, Result<List<UserDto>>>
{
    private readonly AppDbContext _db;

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

    public override void Configure(EndpointRouteConfigure b)
    {
        b.Version(1);
        b.Get("/users");
        b.AllowAnonymous();
    }

    public override async Task<Result<List<UserDto>>> ExecuteAsync(GetUsersRequest req, CancellationToken ct)
    {
        var users = await _db.Users
            .AsNoTracking()
            .Select(u => new UserDto { Id = u.Id, Name = u.FullName })
            .ToListAsync(ct);

        return Success("Users retrieved", users);
    }
}

Run dotnet run and your endpoint is live at GET /v1/users. No manual route registration needed.


Endpoints

Substratum provides four endpoint base types:

Type Use Case
Endpoint<TRequest, TResponse> Standard request/response (JSON)
Endpoint<TRequest> No response body (file downloads, redirects)
StreamEndpoint<TRequest> Server-Sent Events (untyped)
StreamEndpoint<TRequest, TResponse> Server-Sent Events (typed)

Endpoint Structure

Each endpoint lives in its own folder with up to 5 files. The CLI generates all of them:

dotnet-sub new endpoint Users/CreateUser --method POST --route "/users" --permission "Users_Create"
Features/Users/CreateUser/
    CreateUserEndpoint.cs       # Handler logic
    CreateUserRequest.cs        # Input model
    CreateUserResponse.cs       # Output model
    CreateUserRequestValidator.cs  # Validation rules
    CreateUserSummary.cs        # OpenAPI description

Configuration Builder

Every endpoint configures its route, auth, and behavior in the Configure method:

public override void Configure(EndpointRouteConfigure b)
{
    b.Version(1);                                          // Route prefix: /v1
    b.Post("/orders");                                     // HTTP method + route
    b.PermissionsAll(AppPermissions.Orders_Create);        // Require ALL listed permissions
    b.PermissionsAny(AppPermissions.Orders_Create,         // Require ANY of the listed
                     AppPermissions.Orders_Admin);
    b.AllowAnonymous();                                    // Skip authentication
    b.AuthenticationSchemes("Bearer");                     // Pin to specific scheme
    b.DocGroup(DocGroups.AdminApi);                        // Assign to API doc group
    b.Tags("Orders");                                      // OpenAPI tags
    b.AllowFileUploads();                                  // Enable multipart uploads
    b.AllowFormData();                                     // Enable form binding
    b.PreProcessor<AuditPreProcessor>();                   // Run before handler
    b.PostProcessor<LoggingPostProcessor>();                // Run after handler
    b.Options(o => o.RequireRateLimiting("strict"));       // Raw ASP.NET Core options
}

Complete Endpoint Example

public class CreateUserEndpoint : Endpoint<CreateUserRequest, Result<CreateUserResponse>>
{
    private readonly AppDbContext _db;
    private readonly IStringLocalizer<SharedResource> _localizer;

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

    public override void Configure(EndpointRouteConfigure b)
    {
        b.Version(1);
        b.Post("/users");
        b.PermissionsAll(AppPermissions.Users_Create);
    }

    public override async Task<Result<CreateUserResponse>> ExecuteAsync(CreateUserRequest req, CancellationToken ct)
    {
        var exists = await _db.Users.AsNoTracking().AnyAsync(u => u.Username == req.Username, ct);
        if (exists)
            return Failure<CreateUserResponse>(400, _localizer["DuplicatedUsername"]);

        var user = new User
        {
            Id = Guid.CreateVersion7(),
            FullName = req.FullName,
            Username = req.Username,
            PasswordHash = req.Password,
            RoleId = req.RoleId,
        };

        await _db.Users.AddAsync(user, ct);
        await _db.SaveChangesAsync(ct);

        return Success(_localizer["DataCreatedSuccessfully"], new CreateUserResponse { Id = user.Id });
    }
}

Void Endpoints

For file downloads, redirects, or custom responses that write directly to HttpContext:

public class DownloadFileEndpoint : Endpoint<DownloadFileRequest>
{
    public override void Configure(EndpointRouteConfigure b)
    {
        b.Version(1);
        b.Get("/files/{id}/download");
    }

    public override async Task ExecuteAsync(DownloadFileRequest req, CancellationToken ct)
    {
        var stream = await fileStorage.DownloadAsync($"files/{req.Id}", ct);
        HttpContext.Response.ContentType = "application/octet-stream";
        await stream.CopyToAsync(HttpContext.Response.Body, ct);
    }
}

Pre/Post Processors

Add cross-cutting logic that runs before or after endpoint execution:

public class AuditPreProcessor : IPreProcessor<CreateOrderRequest>
{
    public Task ProcessAsync(CreateOrderRequest req, HttpContext ctx, CancellationToken ct)
    {
        // Log, validate, modify request before endpoint runs
        return Task.CompletedTask;
    }
}

public class LoggingPostProcessor : IPostProcessor<CreateOrderRequest, Result<CreateOrderResponse>>
{
    public Task ProcessAsync(CreateOrderRequest req, Result<CreateOrderResponse>? res,
        HttpContext ctx, Exception? exception, CancellationToken ct)
    {
        // Log result, handle errors, cleanup after endpoint runs
        return Task.CompletedTask;
    }
}

Register on an endpoint:

b.PreProcessor<AuditPreProcessor>();
b.PostProcessor<LoggingPostProcessor>();

Validation

Validators use FluentValidation. Extend Validator<T> and they're auto-discovered:

public class CreateUserRequestValidator : Validator<CreateUserRequest>
{
    public CreateUserRequestValidator(AppDbContext db)
    {
        RuleFor(x => x.FullName).NotEmpty();
        RuleFor(x => x.Username)
            .NotEmpty()
            .MustAsync(async (username, ct) =>
                !await db.Users.AsNoTracking().AnyAsync(u => u.Username == username, ct))
            .WithMessage("Username already exists");
        RuleFor(x => x.Password).NotEmpty().MinimumLength(6);
    }
}

Validation runs automatically before your endpoint. Failures return a 422 response with RFC 9457 Problem Details. Validators support constructor injection for database checks and other async rules.


Result Pattern

Wrap your response in Result<T> for standardized success/error responses:

// Success — returns HTTP 200
return Success("Users retrieved successfully", data);

// Failure — sets HTTP status code and returns error
return Failure<UserDto>(404, "User not found");
return Failure<UserDto>(400, "Validation failed", new[] { "Email is required" });

Response format:

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

When no data is needed, use Unit:

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

Pagination

Built-in PaginatedResult<T> works directly with EF Core queries:

// Simple pagination
var result = await PaginatedResult<User>.CreateAsync(
    _db.Users.OrderBy(u => u.FullName),
    req.PageNumber, req.PageSize, ct);

// With projection (maps entity to DTO in the query)
var result = await PaginatedResult<UserDto>.CreateAsync(
    _db.Users.OrderBy(u => u.FullName),
    u => new UserDto { Id = u.Id, Name = u.FullName },
    req.PageNumber, req.PageSize, ct);

Response includes PageNumber, TotalPages, TotalCount, HasPreviousPage, HasNextPage, and the Items array.


Entities

All database entities extend BaseEntity<T>:

public sealed class User : BaseEntity<Guid>
{
    public required string FullName { get; set; }
    public string? Username { get; set; }
    public required Guid RoleId { get; set; }

    public Role Role { get; private set; } = null!;
    public ICollection<Ticket> Tickets { get; private set; } = new HashSet<Ticket>();
}

BaseEntity<T> provides:

Property Description
Id Primary key (e.g., Guid)
CreatedAt Set automatically on insert
UpdatedAt Set automatically on save
IsDeleted Soft delete flag
DeletedAt Auto-set when IsDeleted = true, cleared when set back to false

Soft delete:

user.IsDeleted = true;   // DeletedAt is set automatically
await _db.SaveChangesAsync(ct);

Scaffold a new entity with the CLI:

dotnet-sub new entity Product

Authentication

Four authentication schemes, independently enabled via appsettings.json. When multiple are active, they combine automatically into a unified policy scheme.

JWT Bearer

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

Inject IJwtBearer to create tokens:

// Simple access token
var (token, sessionId, expiration) = jwtBearer.CreateToken(user.Id);

// Access + refresh token pair (requires IRefreshTokenStore)
var result = await jwtBearer.CreateTokenPairAsync(user.Id, ct);
// result.AccessToken, result.RefreshToken, result.SessionId

// Refresh later
var refreshed = await jwtBearer.RefreshAsync(refreshToken, ct);

Token rotation is built-in: each refresh invalidates the old token and issues a new pair.

To use refresh tokens, implement IRefreshTokenStore:

public class RefreshTokenStore : IRefreshTokenStore
{
    public Task StoreAsync(Guid userId, Guid sessionId, string tokenHash,
        DateTimeOffset expiration, CancellationToken ct) { ... }
    public Task<RefreshTokenValidationResult?> ValidateAndRevokeAsync(string tokenHash, CancellationToken ct) { ... }
    public Task RevokeBySessionAsync(Guid sessionId, CancellationToken ct) { ... }
    public Task RevokeAllAsync(Guid userId, CancellationToken ct) { ... }
}
{
  "Authentication": {
    "Cookie": {
      "Enabled": true,
      "Options": {
        "Scheme": "Cookies",
        "CookieName": ".Substratum.Auth",
        "Expiration": "365.00:00:00",
        "SlidingExpiration": true,
        "Secure": true,
        "HttpOnly": true,
        "SameSite": "Lax"
      }
    }
  }
}

Inject ICookieAuth:

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

Basic Authentication

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

Requires implementing IBasicAuthValidator:

public class BasicAuthValidator : IBasicAuthValidator
{
    public Task<(bool Result, string UserId, string SessionId)> ValidateAsync(
        HttpContext context, string username, string password, CancellationToken ct) { ... }
}

API Key Authentication

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

Requires implementing IApiKeyValidator:

public class ApiKeyValidator : IApiKeyValidator
{
    public Task<(bool Result, string UserId, string SessionId)> ValidateAsync(
        HttpContext context, string accessKey, CancellationToken ct) { ... }
}

Multi-App Authentication

When a single backend serves multiple frontends (web, mobile, admin):

// Pass appId when creating tokens
var (token, sessionId, expiration) = jwtBearer.CreateToken(userId, appId: "mobile-app");

// Available via ICurrentUser.AppId

Optionally validate app IDs by implementing IAppResolver.

Current User

Inject ICurrentUser to access the authenticated user anywhere:

_currentUser.UserId       // Guid? — from "uid" claim
_currentUser.AppId        // string? — from "aid" claim
_currentUser.Permissions  // PermissionDefinition[] — loaded via IPermissionHydrator

Session Validation

Implement ISessionValidator to check sessions on every authenticated request (e.g., to block revoked sessions):

public class SessionValidator : ISessionValidator
{
    public async Task<bool> ValidateAsync(HttpContext context, string userId, string sessionId)
    {
        var db = context.RequestServices.GetRequiredService<AppDbContext>();
        var session = await db.UserSessions.AsNoTracking()
            .FirstOrDefaultAsync(s => s.Id == Guid.Parse(sessionId) && s.UserId == Guid.Parse(userId));

        return session is not null && session.RevokedAt is null && session.ExpiresAt > DateTimeOffset.UtcNow;
    }
}

Password Hasher

Available as IPasswordHasher (singleton) or PasswordHasher.Instance. Uses PBKDF2/HMAC-SHA256 with 600,000 iterations:

var hash = passwordHasher.HashPassword("mysecretpassword");
bool valid = passwordHasher.VerifyHashedPassword(hash, "mysecretpassword", out bool needsRehash);

Two-Factor Authentication (TOTP)

Inject ITotpProvider for TOTP-based 2FA:

var secret = totpProvider.GenerateSecret();
var qrUri = totpProvider.GenerateQrCodeUri(secret, "user@example.com", "MyApp");
bool isValid = totpProvider.ValidateCode(secret, "123456");

Permissions

Define permissions as static fields on a partial class implementing IPermissionRegistry:

public static partial class AppPermissions : IPermissionRegistry
{
    public static readonly PermissionDefinition Users_Create = new(
        code: "users.create",
        name: "Users_Create",
        displayName: "Create User",
        groupCode: "users",
        groupName: "Users",
        groupDisplayName: "User Management"
    );

    public static readonly PermissionDefinition Users_View = new(
        code: "users.view",
        name: "Users_View",
        displayName: "View Users",
        groupCode: "users",
        groupName: "Users",
        groupDisplayName: "User Management"
    );
}

The source generator automatically creates:

  • Parse(code) / TryParse(code, out result) methods
  • Definitions() returning all permissions
  • HasPermission, HasAnyPermission, HasAllPermissions extension methods

Endpoint Authorization

b.PermissionsAll(AppPermissions.Users_Create);  // Require ALL
b.PermissionsAny(AppPermissions.Users_View, AppPermissions.Users_ViewOwn);  // Require ANY

Runtime Permission Checks

if (_currentUser.Permissions.HasPermission(AppPermissions.Tickets_View))
{
    // user can view all tickets
}

if (!_currentUser.Permissions.HasAnyPermission(AppPermissions.Tickets_View, AppPermissions.Tickets_ViewOwn))
{
    return Failure<TicketDto>(403, "Forbidden");
}

Permission Hydration

Implement IPermissionHydrator to load permissions from your database into the authenticated user's claims:

public class PermissionHydrator : IPermissionHydrator
{
    public async Task<PermissionDefinition[]?> HydrateAsync(
        IServiceProvider serviceProvider, string userId, CancellationToken ct)
    {
        var db = serviceProvider.GetRequiredService<AppDbContext>();
        return await db.Users.AsNoTracking()
            .Where(u => u.Id == Guid.Parse(userId))
            .Select(u => u.Role.Permissions)
            .FirstOrDefaultAsync(ct);
    }
}

EF Core Integration

PermissionDefinition[] properties are automatically stored as JSON arrays in the database — no manual conversion needed:

public sealed class Role : BaseEntity<Guid>
{
    public required string Name { get; set; }
    public required PermissionDefinition[] Permissions { get; set; }  // Stored as ["users.create", "users.view"]
}

Events

Substratum includes an in-process event bus for domain events.

Define an Event and Handler

public sealed record TicketCreatedEvent(Guid TicketId, Guid UserId);

public sealed class TicketCreatedEventHandler : IEventHandler<TicketCreatedEvent>
{
    private readonly AppDbContext _db;

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

    public async Task HandleAsync(TicketCreatedEvent @event, CancellationToken ct)
    {
        await _db.TicketEvents.AddAsync(new TicketEvent
        {
            Id = Guid.CreateVersion7(),
            TicketId = @event.TicketId,
            UserId = @event.UserId,
            EventType = TicketEventType.Created,
        }, ct);
        await _db.SaveChangesAsync(ct);
    }
}

Publish Events

await _eventBus.PublishAsync(new TicketCreatedEvent(ticket.Id, _currentUser.UserId!.Value), ct);

Event handlers are auto-discovered and registered by the source generator. Scaffold with:

dotnet-sub new event --group Tickets --endpoint CreateTicket --name TicketCreated

Live Events (Real-time SSE)

Push server events to connected clients in real-time via Server-Sent Events.

Enable

{
  "LiveEvents": {
    "Enabled": true,
    "Options": {
      "Path": "/v1/live-events",
      "ReconnectGracePeriodSeconds": 15,
      "KeepAliveIntervalSeconds": 30,
      "Provider": "Memory"
    }
  }
}

Use "Provider": "Redis" for multi-server deployments.

Auto-Push via EventBus

Mark any event with ILiveEvent to automatically push it to subscribed SSE clients:

public sealed record OrderCreatedEvent(Guid OrderId, string CustomerName) : ILiveEvent;

// Publishing via EventBus automatically pushes to SSE clients
await _eventBus.PublishAsync(new OrderCreatedEvent(order.Id, order.CustomerName), ct);

Direct Push

Inject LiveEventDispatcher for manual control:

// Push to subscribers of an event name
_liveEvents.Push("Notification", new { Message = "Hello!" });

// Push to a specific user
_liveEvents.PushToUser(userId, "Alert", new { Level = "warning" });

// Broadcast to everyone
_liveEvents.Broadcast("SystemMessage", new { Text = "Maintenance in 5 min" });

Client-Side

const sse = new EventSource('/v1/live-events', { withCredentials: true });

sse.addEventListener('OrderCreatedEvent', (e) => {
    console.log('New order:', JSON.parse(e.data));
});

// Subscribe to events
await fetch('/v1/live-events/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ events: ['OrderCreatedEvent', 'ChatMessage'] })
});

If a client disconnects and reconnects within the grace period, subscriptions are automatically restored.

Connection Observer

Implement ILiveEventObserver to react when users come online or go offline:

public class LiveEventObserver : ILiveEventObserver
{
    private readonly AppDbContext _db;

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

    public async Task OnConnectedAsync(Guid userId, bool isFirstConnection, CancellationToken ct)
    {
        if (isFirstConnection)
        {
            var user = await _db.Users.FindAsync([userId], ct);
            if (user is not null)
            {
                user.IsOnline = true;
                await _db.SaveChangesAsync(ct);
            }
        }
    }

    public async Task OnDisconnectedAsync(Guid userId, bool isLastConnection, CancellationToken ct)
    {
        if (isLastConnection)
        {
            var user = await _db.Users.FindAsync([userId], ct);
            if (user is not null)
            {
                user.IsOnline = false;
                user.LastSeenAt = DateTimeOffset.UtcNow;
                await _db.SaveChangesAsync(ct);
            }
        }
    }
}
  • isFirstConnection is true when this is the user's first active SSE connection (they just came online).
  • isLastConnection is true when the user has no more active connections (they just went fully offline).

The observer is auto-discovered by the source generator. Only one implementation is allowed.

Authorization

Implement ILiveEventAuthorizer to control who can subscribe to which events:

public class LiveEventAuthorizer : ILiveEventAuthorizer
{
    public Task<bool> CanSubscribeAsync(Guid userId, string eventName, CancellationToken ct)
    {
        return Task.FromResult(!eventName.StartsWith("Admin") || IsAdmin(userId));
    }
}

Audit Logging

Track all entity changes automatically.

Same-Transaction Audit

Make your DbContext implement IAuditableLog:

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options), IAuditableLog
{
    public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
    // ... your other DbSets
}

Audit records are written in the same SaveChangesAsync call as your changes. Each record captures the entity type, entity ID, action (Create/Update/Delete), timestamp, user ID, and property-level old/new values.

External Audit Store

For storing audit logs externally, implement IAuditStore:

public class ExternalAuditStore : IAuditStore
{
    public Task SaveAsync(IReadOnlyList<AuditEntry> entries, CancellationToken ct)
    {
        // Send to external logging service, message queue, etc.
    }
}

Entity Framework

Configuration

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

Supported providers: SqlServer, Npgsql (PostgreSQL), Sqlite, MySql.

All providers automatically get snake_case naming, check constraints, and configurable retry policies.

Define Your DbContext

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

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

The source generator auto-discovers and wires your DbContext. For a single DbContext, no attribute is needed (defaults to the "Default" section).

Multiple DbContexts

Tag each context with [DbContextName]:

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

[DbContextName("Analytics")]
public class AnalyticsDbContext : DbContext { ... }
{
  "EntityFramework": {
    "Default": { "Provider": "Npgsql", "ConnectionString": "..." },
    "Analytics": { "Provider": "SqlServer", "ConnectionString": "..." }
  }
}

Second-Level Cache

Caches query results to avoid repeated database hits. Supports Memory and Redis providers.

Use .Cacheable() in queries to opt in:

var permissions = await _db.Users.AsNoTracking()
    .Where(u => u.Id == userId)
    .Select(u => u.Role.Permissions)
    .Cacheable()
    .FirstOrDefaultAsync(ct);

Database Seeding

When EnableSeeding is true, implement IDbContextInitializer<T>:

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

Migrations

dotnet-sub migrations add InitialCreate
dotnet-sub migrations add AddProducts --context AnalyticsDbContext

dotnet-sub database update
dotnet-sub database sql

File Storage

A unified interface for Local, AWS S3, and Azure Blob Storage:

{
  "FileStorage": {
    "Enabled": true,
    "Options": {
      "Provider": "Local",
      "Container": "uploads",
      "MaxFileSizeBytes": 52428800,
      "AllowedExtensions": [".jpg", ".png", ".pdf", ".docx"]
    }
  }
}

Inject IFileStorage:

// Upload
await fileStorage.UploadAsync("documents/report.pdf", fileStream, "application/pdf", 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);

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

Providers: Local, S3 (requires AWS S3 config), AzureBlob (requires Azure Blob Storage config).


Image Processing

IImageService is always available (singleton). Handles resizing, WebP conversion, and BlurHash generation:

// 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 for progressive loading
string blurHash = await imageService.BlurHashAsync(uploadedFile.OpenReadStream());
// Returns: "LEHV6nWB2yk8pyo0adR*.7kCMdnj"

Default options: 512x512 max, 70 quality, WebP output, metadata stripped.


Localization

1. Create .resx resource files:

Resources/SharedResource.en.resx
Resources/SharedResource.ar.resx

2. Create a marker class:

public class SharedResource { }

3. Set default culture:

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

4. Inject and use:

private readonly IStringLocalizer<SharedResource> _localizer;

return Success(_localizer["DataRetrievedSuccessfully"], data);

Supported cultures are auto-detected from your .resx file names.


OpenAPI Documentation

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

API docs are served at /scalar/{version} using Scalar UI. Endpoint summaries provide descriptions:

public partial class ListUsersSummary : EndpointSummary<ListUsersEndpoint>
{
    protected override void Configure()
    {
        Description = "Lists all users with pagination support.";
    }
}

Document Groups

Split your API docs into separate sections (public, admin, etc.):

public static partial class DocGroups
{
    public static readonly DocGroupDefinition PublicApi = new(
        name: "Public API", url: "public", isDefault: true);

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

Assign endpoints: b.DocGroup(DocGroups.AdminApi);

Groups with a permission are access-controlled via IApiKeyValidator or IBasicAuthValidator.


Firebase

Cloud Messaging (Push Notifications)

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

When enabled, FirebaseMessaging is available for injection to send push notifications.

App Check

{
  "Firebase": {
    "AppCheck": {
      "Enabled": true,
      "Options": {
        "ProjectId": "YOUR_PROJECT_ID",
        "ProjectNumber": "YOUR_PROJECT_NUMBER",
        "EnableEmulator": false
      }
    }
  }
}

Inject IFirebaseAppCheck:

bool valid = await appCheck.VerifyAppCheckTokenAsync(token, ct);

Infrastructure

All infrastructure features are configured in appsettings.json.

CORS

{
  "Cors": {
    "AllowedOrigins": ["https://localhost:3000"],
    "AllowedMethods": ["GET", "POST", "PUT", "DELETE", "PATCH"],
    "AllowedHeaders": ["Content-Type", "Authorization"],
    "AllowCredentials": true,
    "MaxAgeSeconds": 600
  }
}

Rate Limiting

{
  "RateLimiting": {
    "Enabled": true,
    "Options": {
      "GlobalPolicy": "Default",
      "RejectionStatusCode": 429,
      "Policies": {
        "Default": { "Type": "FixedWindow", "PermitLimit": 100, "WindowSeconds": 60 },
        "Strict": { "Type": "SlidingWindow", "PermitLimit": 10, "WindowSeconds": 60, "SegmentsPerWindow": 6 }
      }
    }
  }
}

Policy types: FixedWindow, SlidingWindow, TokenBucket, Concurrency.

Apply to a specific endpoint: b.Options(o => o.RequireRateLimiting("Strict"));

Custom partition key: implement IRateLimitPartitioner.

Distributed Cache

{
  "DistributedCache": {
    "Enabled": true,
    "Options": {
      "Provider": "Redis",
      "Redis": { "ConnectionString": "localhost:6379", "InstanceName": "DC_" }
    }
  }
}

Providers: Memory (single server) or Redis (multi-server). Registers IDistributedCache.

Health Checks

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

Automatically includes DbContext health checks. Add custom checks:

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

Response Compression

{
  "ResponseCompression": {
    "Enabled": true,
    "Options": { "EnableForHttps": true, "Providers": ["Brotli", "Gzip"] }
  }
}

Forwarded Headers

For apps behind a reverse proxy:

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

Request Limits

{
  "RequestLimits": {
    "MaxRequestBodySizeBytes": 52428800,
    "MaxMultipartBodyLengthBytes": 134217728
  }
}

Static Files

{
  "StaticFiles": {
    "Enabled": true,
    "Options": { "RootPath": "wwwroot", "RequestPath": "" }
  }
}

Error Handling

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

Set IncludeExceptionDetails to false in production.

Logging (Serilog)

Configured via standard Serilog appsettings.json config. Built-in sensitive data masking:

{
  "Serilog": {
    "MinimumLevel": { "Default": "Information" },
    "Enrich": [
      "FromLogContext",
      {
        "Name": "WithSensitiveDataMasking",
        "Args": {
          "options": {
            "MaskValue": "*****",
            "MaskProperties": [{ "Name": "Password" }, { "Name": "SecretKey" }]
          }
        }
      }
    ],
    "WriteTo": [{ "Name": "Console" }]
  }
}

AWS Secrets Manager

Load secrets into IConfiguration at startup:

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

Cloud Storage Providers

{
  "Aws": {
    "S3": {
      "Enabled": true,
      "Options": { "Region": "us-east-1", "AccessKey": "...", "SecretKey": "..." }
    }
  },
  "Azure": {
    "BlobStorage": {
      "Enabled": true,
      "Options": { "ConnectionString": "..." }
    }
  }
}

When enabled, IAmazonS3 and BlobServiceClient are registered for direct injection.


CLI Tool (dotnet-sub)

dotnet tool install --global Substratum.Tools

Scaffold a Project

dotnet-sub new webapp MyApp

Creates a complete project with security, database, entities, features, and localization.

Scaffold an Endpoint

dotnet-sub new endpoint Users/CreateUser --method POST --route "/users" --permission "Users_Create"
dotnet-sub new endpoint Users/ListUsers --method GET --route "/users" --paginated

Scaffold an Entity

dotnet-sub new entity Product

Scaffold an Event

dotnet-sub new event --group Tickets --endpoint CreateTicket --name TicketCreated

Scaffold a Background Job

dotnet-sub new job --name SendEmailJob --scope Global --type Simple
dotnet-sub new job --name ProcessOrderJob --scope Group --group Orders --type WithArgs

Database Commands

dotnet-sub migrations add InitialCreate
dotnet-sub migrations add AddProducts --context AnalyticsDbContext
dotnet-sub database update
dotnet-sub database update --context AnalyticsDbContext
dotnet-sub database sql
dotnet-sub database sql --from InitialCreate --to AddProducts

Code Migration (v1 to v2)

dotnet-sub migrate              # Auto-fix breaking changes
dotnet-sub migrate --dry-run    # Preview changes

MCP Server

Substratum includes an MCP (Model Context Protocol) server that exposes the framework's capabilities to AI assistants like Claude. Install as a global tool:

dotnet tool install --global Substratum.Mcp

The MCP server provides tools for:

  • Scaffolding — create projects, endpoints, entities, events, jobs, services, permissions, doc groups
  • Code generation — generate endpoint logic, validation rules
  • Intelligence — analyze project structure, get coding conventions, schema reference, setup guides
  • Design — validate backend designs, check for missing FKs, naming violations
  • Database — add migrations, update database, generate SQL, design seed data
  • Configuration — read and update appsettings.json
  • Skills — best-practice rules for database design, entity design, endpoint design, validation, EF Core, LINQ, security

Reference

Interfaces You Implement

Interface When 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 keys
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
IAuditStore Optional External audit log storage
ILiveEventAuthorizer Optional Control Live Events subscriptions
ILiveEventObserver Optional React to user connect/disconnect (online status)
IEventHandler<T> Per event Handle domain events

Services You Inject

Service Available Description
IJwtBearer When JWT enabled Create/refresh JWT tokens
ICookieAuth When Cookie enabled Sign in/out with cookies
ICurrentUser Always Authenticated user (UserId, AppId, Permissions)
IPasswordHasher Always Hash and verify passwords
ITotpProvider Always TOTP 2FA operations
IImageService Always Image processing + BlurHash
IFileStorage When FileStorage enabled Unified file storage
IFirebaseAppCheck When AppCheck enabled Verify Firebase App Check tokens
EventBus Always Publish domain events
LiveEventDispatcher When LiveEvents enabled Push real-time SSE events
IDistributedCache When DistributedCache enabled Distributed caching

Base Classes

Class Description
Endpoint<TRequest, TResponse> Standard request/response endpoint
Endpoint<TRequest> Void endpoint (files, redirects)
StreamEndpoint<TRequest> SSE with untyped items
StreamEndpoint<TRequest, TResponse> SSE with typed items
BaseEntity<T> Database entity (soft delete, timestamps)
Validator<T> FluentValidation validator
EndpointSummary<T> OpenAPI endpoint description

Data Types

Type Description
Result<T> Standard API response (code, message, data, errors)
PaginatedResult<T> Paginated query result
Unit Empty response type
PermissionDefinition Permission with code, name, group
DocGroupDefinition API documentation group

Full Configuration Reference

<details> <summary>Click to expand the complete appsettings.json</summary>

{
  "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": "" }
  },
  "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_PROJECT_ID",
        "ProjectNumber": "YOUR_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
        }
      }
    }
  },
  "LiveEvents": {
    "Enabled": false,
    "Options": {
      "Path": "/v1/live-events",
      "ReconnectGracePeriodSeconds": 15,
      "KeepAliveIntervalSeconds": 30,
      "CleanupIntervalSeconds": 10,
      "Provider": "Memory",
      "Redis": { "ConnectionString": "", "ChannelPrefix": "live-events" }
    }
  }
}

</details>


License

MIT

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
2.15.0 31 4/11/2026
2.14.0 26 4/11/2026
2.12.0 30 4/11/2026
2.10.0 36 4/10/2026
2.9.0 78 4/7/2026
2.8.0 86 4/3/2026
2.6.0 111 3/28/2026
2.5.1 86 3/28/2026
2.5.0 89 3/27/2026
2.4.0 94 3/27/2026
2.3.3 92 3/27/2026
2.3.1 77 3/27/2026
2.1.1 88 3/26/2026
2.1.0 81 3/26/2026
2.0.0 86 3/26/2026
1.1.1 83 3/24/2026
1.1.0 78 3/24/2026
1.0.8 84 3/24/2026
1.0.6 89 3/23/2026
1.0.5 75 3/23/2026
Loading failed