Substratum 2.14.0
See the version list below for details.
dotnet add package Substratum --version 2.14.0
NuGet\Install-Package Substratum -Version 2.14.0
<PackageReference Include="Substratum" Version="2.14.0" />
<PackageVersion Include="Substratum" Version="2.14.0" />
<PackageReference Include="Substratum" />
paket add Substratum --version 2.14.0
#r "nuget: Substratum, 2.14.0"
#:package Substratum@2.14.0
#addin nuget:?package=Substratum&version=2.14.0
#tool nuget:?package=Substratum&version=2.14.0
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
- Quick Start
- Endpoints
- Validation
- Result Pattern
- Pagination
- Entities
- Authentication
- Permissions
- Events
- Live Events (Real-time SSE)
- Audit Logging
- Entity Framework
- File Storage
- Image Processing
- Localization
- OpenAPI Documentation
- Firebase
- Infrastructure
- CLI Tool (dotnet-sub)
- MCP Server
- Reference
- Full Configuration Reference
- License
Packages
| Package | NuGet | Description |
|---|---|---|
Substratum |
Runtime library | |
Substratum.Generator |
Source generators — zero reflection, AOT-compatible | |
Substratum.Tools |
CLI tool (dotnet-sub) — scaffolding, migrations |
Add to your .csproj:
<PackageReference Include="Substratum" Version="2.14.0" />
<PackageReference Include="Substratum.Generator" Version="2.14.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) { ... }
}
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"
}
}
}
}
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)methodsDefinitions()returning all permissionsHasPermission,HasAnyPermission,HasAllPermissionsextension 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);
}
}
}
}
isFirstConnectionistruewhen this is the user's first active SSE connection (they just came online).isLastConnectionistruewhen 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 | Versions 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. |
-
net10.0
- AspNetCore.Authentication.ApiKey (>= 9.0.0)
- AWSSDK.S3 (>= 4.0.21.1)
- AWSSDK.SecretsManager (>= 4.0.4.17)
- Azure.Storage.Blobs (>= 12.27.0)
- Blurhash.ImageSharp (>= 4.0.1)
- EFCore.CheckConstraints (>= 10.0.0)
- EFCore.NamingConventions (>= 10.0.1)
- EFCoreSecondLevelCacheInterceptor (>= 5.3.9)
- EFCoreSecondLevelCacheInterceptor.MemoryCache (>= 5.3.9)
- EFCoreSecondLevelCacheInterceptor.StackExchange.Redis (>= 5.3.9)
- EntityFrameworkCore.Projectables (>= 6.0.2)
- FirebaseAdmin (>= 3.5.0)
- FluentValidation (>= 12.1.1)
- Kralizek.Extensions.Configuration.AWSSecretsManager (>= 1.7.0)
- LLL.AutoCompute.EFCore (>= 1.6.0)
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 10.0.5)
- Microsoft.AspNetCore.OpenApi (>= 10.0.5)
- Microsoft.EntityFrameworkCore (>= 10.0.5)
- Microsoft.EntityFrameworkCore.InMemory (>= 10.0.5)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.5)
- Microsoft.EntityFrameworkCore.Sqlite (>= 10.0.5)
- Microsoft.EntityFrameworkCore.SqlServer (>= 10.0.5)
- Microsoft.Extensions.Caching.StackExchangeRedis (>= 10.0.5)
- Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore (>= 10.0.5)
- Microting.EntityFrameworkCore.MySql (>= 10.0.5)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 10.0.1)
- Otp.NET (>= 1.4.1)
- Scalar.AspNetCore (>= 2.13.22)
- Serilog (>= 4.3.1)
- Serilog.AspNetCore (>= 10.0.0)
- Serilog.Enrichers.Sensitive (>= 2.1.0)
- SixLabors.ImageSharp (>= 3.1.12)
- ZNetCS.AspNetCore.Authentication.Basic (>= 10.0.0)
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 | 35 | 4/11/2026 |
| 2.14.0 | 30 | 4/11/2026 |
| 2.12.0 | 34 | 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 | 112 | 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 |