Substratum.Generator
3.0.0-alpha.1
dotnet add package Substratum.Generator --version 3.0.0-alpha.1
NuGet\Install-Package Substratum.Generator -Version 3.0.0-alpha.1
<PackageReference Include="Substratum.Generator" Version="3.0.0-alpha.1"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
<PackageVersion Include="Substratum.Generator" Version="3.0.0-alpha.1" />
<PackageReference Include="Substratum.Generator"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
paket add Substratum.Generator --version 3.0.0-alpha.1
#r "nuget: Substratum.Generator, 3.0.0-alpha.1"
#:package Substratum.Generator@3.0.0-alpha.1
#addin nuget:?package=Substratum.Generator&version=3.0.0-alpha.1&prerelease
#tool nuget:?package=Substratum.Generator&version=3.0.0-alpha.1&prerelease

Substratum
The batteries-included backend framework for ASP.NET Core. Ship a production-grade API in minutes — not weeks.
Substratum is an opinionated, production-grade application framework for ASP.NET Core. Authentication, authorization, database, caching, logging, OpenAPI docs, cloud storage, push notifications, real-time events, background jobs, rate limiting, and more — all pre-wired and configured via appsettings.json.
Write your business logic. Substratum handles the rest.
// Your entire Program.cs
return await SubstratumApp.RunAsync(args);
That's it. One line. Everything else — endpoints, validators, event handlers, DbContexts, permissions, auth, OpenAPI — is discovered and wired automatically.
All discovery happens at compile time via the source generator — Substratum's runtime is reflection-free: no assembly scanning, no attribute reflection, no runtime code generation.
Why Substratum?
| Without Substratum | With Substratum | |
|---|---|---|
| New project | Hours of boilerplate | dotnet-sub new webapp MyApp — done |
| Auth (JWT + refresh + cookies + API keys) | 500+ lines of wiring | Toggle flags in appsettings.json |
| Permissions | Hand-rolled string constants | Type-safe, auto-discovered, EF-stored as JSON |
| Real-time push | Install SignalR, wire hubs, auth, Redis backplane | Enabled: true — done |
| Endpoint routing | Repeat .MapPost(...) dozens of times |
Inherit Endpoint<TReq, TRes>, it maps itself |
| Validation | Manual pipeline | Inherit Validator<T> — auto-runs, auto-returns 422 |
| OpenAPI | Decorate everything | Generated from your endpoint signatures |
| Pagination, Result pattern, soft deletes, audit logs | Roll your own | Built in |
Built on top of — not replacing — ASP.NET Core. Every endpoint is a real Minimal API route. No reflection at runtime. Source-generated. AOT-friendly.
Performance
Substratum's opinionation costs ~0%. Full HTTP round-trips through Kestrel, measured across five scenarios against Microsoft.AspNetCore.App Minimal APIs (baseline) and FastEndpoints.
| Scenario | Minimal API | Substratum | FastEndpoints |
|---|---|---|---|
GET list — /v1/forecasts |
51.06 μs — 5.10 KB | 51.30 μs (1.00×) — 5.26 KB | 51.91 μs (1.02×) — 6.40 KB |
GET by ID — /v1/forecasts/42 |
49.98 μs — 4.26 KB | 49.60 μs (1.01× faster) — 4.43 KB | 52.66 μs (1.05×) — 6.09 KB |
POST validated — /v1/orders |
60.08 μs — 18.02 KB | 60.09 μs (1.00×) — 17.88 KB | 59.23 μs (1.01× faster) — 10.50 KB |
| POST validation failure | 62.30 μs — 23.82 KB | 62.55 μs (1.00×) — 23.85 KB | 69.04 μs (1.11×) — 18.89 KB |
| Route miss (404) | 47.65 μs — 4.01 KB | 47.90 μs (1.01×) — 4.01 KB | 48.22 μs (1.01×) — 4.47 KB |
Takeaways:
- Substratum is within 1% of raw Minimal API on every scenario — 1% faster on GET-by-ID, 0–1% slower on the others.
- Allocation is effectively identical to Minimal API (
~0.1–0.2 KBdifference per request). - On the validation-failure path Substratum is ~10% faster than FastEndpoints; on GET-by-ID, ~6% faster.
- The
Endpoint<TReq, TRes>class-based abstraction pays no runtime cost — it's all source-generated.
<details> <summary>Benchmark setup</summary>
- BenchmarkDotNet v0.15.8, .NET 10.0.0, Apple M4 Pro (12 cores), macOS 26.1
- 5 warmup + 20 iteration runs, in-process Kestrel,
HttpClientround-trip - Reproduce:
cd benchmarks/Substratum.Benchmarks && dotnet run -c Release -- --filter '*Throughput*' - Full suite: 8 benchmark classes covering throughput, concurrent load, serialization, middleware pipeline, cold-start, minimal-overhead — run with no
--filterfor the interactive menu.
</details>
Table of Contents
- Install
- 60-Second Quick Start
- Endpoints
- Validation
- The Result Pattern
- Pagination
- Entities
- Database (Entity Framework)
- Authentication
- Permissions
- Events & Handlers
- Live Events (Real-time SSE)
- Background Jobs
- Audit Logging
- File Storage
- Image Processing
- Localization
- OpenAPI Docs
- Firebase
- Infrastructure Toggles
- CLI Tool:
dotnet-sub - MCP Server (for AI Assistants)
- Reference
- Full appsettings.json
- License
Install
The packages
| Package | What it is | Install |
|---|---|---|
Substratum |
The runtime library | <PackageReference Include="Substratum" Version="2.60.0" /> |
Substratum.Generator |
Source generators (zero reflection, AOT-friendly) | <PackageReference Include="Substratum.Generator" Version="2.60.0" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> |
Substratum.Tools |
Global CLI (dotnet-sub) — scaffolding, migrations |
dotnet tool install -g Substratum.Tools |
Substratum.Mcp |
MCP server (dotnet-sub-mcp) — lets AI assistants like Claude scaffold, analyze, and migrate your project |
dotnet tool install -g Substratum.Mcp |
Requirements
- .NET 10 SDK or later
- A database (PostgreSQL, SQL Server, SQLite, or MySQL)
- Optional: Redis (for distributed cache, Live Events clustering, refresh tokens)
60-Second Quick Start
1. Scaffold a project
dotnet tool install -g Substratum.Tools
dotnet-sub new webapp MyApp
cd MyApp
You get a complete project: Program.cs, appsettings.json, AppDbContext, AppPermissions, DocGroups, sample endpoint, ready to run.
2. Run it
dotnet run
- API:
http://localhost:5000 - Docs UI:
http://localhost:5000/scalar/v1 - Health:
http://localhost:5000/healthz
3. Create your first endpoint
dotnet-sub new endpoint --group Users --name ListUsers --method Get --route /users
That one command creates the endpoint, its request DTO, response DTO, validator, and OpenAPI summary — already wired, already routed.
4. Write the logic
public class ListUsersEndpoint : Endpoint<ListUsersRequest, Result<List<UserDto>>>
{
private readonly AppDbContext _db;
public ListUsersEndpoint(AppDbContext db) => _db = db;
public override void Configure(EndpointRouteConfigure route)
{
route.Version(1);
route.Get("/users");
route.AllowAnonymous();
}
public override async Task<Result<List<UserDto>>> ExecuteAsync(
ListUsersRequest 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. Endpoint is live at GET /v1/users. You didn't touch Program.cs, didn't register anything, didn't configure a route table.
Adding services manually
If you need to register extra services:
return await SubstratumApp.RunAsync(args, options =>
{
options.Services.AddSingleton<IMyService, MyService>();
options.Services.AddHttpClient<IPaymentsClient, PaymentsClient>();
});
Endpoints
Every endpoint is a class. Substratum provides four base classes:
| Base class | Use for |
|---|---|
Endpoint<TRequest, TResponse> |
Standard JSON request/response |
Endpoint<TRequest> |
No response body (downloads, redirects, custom writes) |
StreamEndpoint<TRequest> |
Server-Sent Events (untyped items) |
StreamEndpoint<TRequest, TResponse> |
Server-Sent Events (typed items) |
Anatomy of an endpoint
Each endpoint lives in its own folder:
Features/Users/CreateUser/
CreateUserEndpoint.cs // Handler
CreateUserRequest.cs // Input
CreateUserResponse.cs // Output
CreateUserRequestValidator.cs // Validation rules
CreateUserSummary.cs // OpenAPI description
All five are generated by one command:
dotnet-sub new endpoint --group Users --name CreateUser --method Post --route /users --permission Users_Create
Route configuration (fluent)
Every endpoint configures its route in the Configure method:
public override void Configure(EndpointRouteConfigure route)
{
route.Version(1); // /v1 prefix
route.Post("/orders"); // HTTP verb + path
route.PermissionsAll(AppPermissions.Orders_Create); // Require ALL listed
route.PermissionsAny( // OR require ANY of
AppPermissions.Orders_Create,
AppPermissions.Orders_Admin);
route.AllowAnonymous(); // Skip auth
route.AuthenticationSchemes("Bearer"); // Pin a scheme
route.DocGroup(DocGroups.AdminApi); // Assign to docs
route.Tags("Orders"); // OpenAPI tag
route.AllowFileUploads(); // Enable multipart
route.AllowFormData(); // Enable form binding
route.PreProcessor<AuditPreProcessor>(); // Run before handler
route.PostProcessor<LoggingPostProcessor>(); // Run after handler
route.Options(o => o.RequireRateLimiting("strict")); // Raw ASP.NET options
}
Full example
public class CreateUserEndpoint : Endpoint<CreateUserRequest, Result<CreateUserResponse>>
{
private readonly AppDbContext _db;
private readonly IStringLocalizer<SharedResource> _t;
public CreateUserEndpoint(AppDbContext db, IStringLocalizer<SharedResource> t)
{
_db = db;
_t = t;
}
public override void Configure(EndpointRouteConfigure route)
{
route.Version(1);
route.Post("/users");
route.PermissionsAll(AppPermissions.Users_Create);
}
public override async Task<Result<CreateUserResponse>> ExecuteAsync(
CreateUserRequest req, CancellationToken ct)
{
if (await _db.Users.AnyAsync(u => u.Username == req.Username, ct))
return Failure<CreateUserResponse>(409, _t["UsernameAlreadyExists"]);
var user = new User
{
Id = Guid.CreateVersion7(),
FullName = req.FullName,
Username = req.Username,
RoleId = req.RoleId,
};
_db.Users.Add(user);
await _db.SaveChangesAsync(ct);
return Success(_t["UserCreated"], new CreateUserResponse { Id = user.Id });
}
}
Void endpoints (downloads, redirects)
public class DownloadFileEndpoint : Endpoint<DownloadFileRequest>
{
public override void Configure(EndpointRouteConfigure route)
{
route.Version(1);
route.Get("/files/{id}/download");
}
public override async Task ExecuteAsync(DownloadFileRequest req, CancellationToken ct)
{
var stream = await _storage.DownloadAsync($"files/{req.Id}", ct);
HttpContext.Response.ContentType = "application/octet-stream";
await stream.CopyToAsync(HttpContext.Response.Body, ct);
}
}
Pre/Post processors
Cross-cutting logic around your handler:
public class AuditPreProcessor : IPreProcessor<CreateOrderRequest>
{
public Task ProcessAsync(CreateOrderRequest req, HttpContext ctx, CancellationToken ct)
{
// before the handler runs — log, mutate, short-circuit
return Task.CompletedTask;
}
}
public class LoggingPostProcessor : IPostProcessor<CreateOrderRequest, Result<CreateOrderResponse>>
{
public Task ProcessAsync(CreateOrderRequest req, Result<CreateOrderResponse>? res,
HttpContext ctx, Exception? exception, CancellationToken ct)
{
// after the handler — log result, swallow errors, measure
return Task.CompletedTask;
}
}
Validation
Extend Validator<T> (a FluentValidation validator) — auto-discovered, auto-wired, auto-run before your handler:
public class CreateUserRequestValidator : Validator<CreateUserRequest>
{
public CreateUserRequestValidator(AppDbContext db)
{
RuleFor(x => x.FullName).NotEmpty().MaximumLength(200);
RuleFor(x => x.Username)
.NotEmpty()
.MustAsync(async (username, ct) =>
!await db.Users.AnyAsync(u => u.Username == username, ct))
.WithMessage("Username already exists");
RuleFor(x => x.Password).NotEmpty().MinimumLength(8);
}
}
- Failures return HTTP 422 with RFC 9457 Problem Details.
- Constructor injection works (services, DbContext, anything).
- No registration code. Ever.
The Result Pattern
Every endpoint returns Result<T> — a uniform success/error envelope:
return Success("Users retrieved", users); // 200 OK
return Failure<UserDto>(404, "User not found"); // 404
return Failure<UserDto>(400, "Bad input", ["Email required"]); // 400 + errors
Response JSON:
{
"code": 0,
"message": "Users retrieved",
"data": [ ... ],
"errors": null
}
code: 0 = success, code: 1 = failure.
No payload? Use Unit:
public class DeleteUserEndpoint : Endpoint<DeleteUserRequest, Result<Unit>>
{
public override async Task<Result<Unit>> ExecuteAsync(DeleteUserRequest req, CancellationToken ct)
{
// ...
return Success("Deleted", new Unit());
}
}
Pagination
Built-in, works directly with EF Core IQueryable:
// Entity in, entity out
var page = await PaginatedResult<User>.CreateAsync(
_db.Users.OrderBy(u => u.FullName),
req.PageNumber, req.PageSize, ct);
// Entity in, DTO out (projection runs in SQL)
var page = await PaginatedResult<UserDto>.CreateAsync(
_db.Users.OrderBy(u => u.FullName),
u => new UserDto { Id = u.Id, Name = u.FullName },
req.PageNumber, req.PageSize, ct);
Includes PageNumber, TotalPages, TotalCount, HasPreviousPage, HasNextPage, Items.
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>();
}
You get:
| Property | Behavior |
|---|---|
Id |
Primary key (typed — Guid, int, long, etc.) |
CreatedAt |
Set on insert, automatically |
UpdatedAt |
Set on save, automatically |
IsDeleted |
Soft delete flag |
DeletedAt |
Auto-set when IsDeleted = true; cleared when false |
Soft delete:
user.IsDeleted = true;
await _db.SaveChangesAsync(ct); // DeletedAt set automatically
Scaffold a new one:
dotnet-sub new entity --name Product
Database (Entity Framework)
Configure a provider
{
"EntityFramework": {
"Default": {
"Provider": "Npgsql",
"ConnectionString": "Host=localhost;Database=mydb;Username=postgres;Password=password",
"CommandTimeoutSeconds": 30,
"EnableSeeding": true,
"Logging": {
"EnableDetailedErrors": true,
"EnableSensitiveDataLogging": false
},
"RetryPolicy": {
"Enabled": true,
"Options": { "MaxRetryCount": 3, "MaxRetryDelaySeconds": 5 }
},
"SecondLevelCache": {
"Enabled": true,
"Options": {
"KeyPrefix": "EF_",
"Provider": "Memory",
"Redis": { "ConnectionString": "", "TimeoutSeconds": 3, "UseSsl": false }
}
}
}
}
}
Supported providers: Npgsql (PostgreSQL), SqlServer, Sqlite, MySql.
Every provider automatically gets snake_case naming, check constraints, and configurable retry policies + second-level caching.
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 mb)
{
base.OnModelCreating(mb);
mb.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
Auto-discovered. No AddDbContext call needed.
Multiple DbContexts
Tag each one:
[DbContextName("Default")]
public class AppDbContext : DbContext { /* ... */ }
[DbContextName("Analytics")]
public class AnalyticsDbContext : DbContext { /* ... */ }
{
"EntityFramework": {
"Default": { "Provider": "Npgsql", "ConnectionString": "..." },
"Analytics": { "Provider": "SqlServer", "ConnectionString": "..." }
}
}
Second-level caching (opt-in per query)
var perms = await _db.Users.AsNoTracking()
.Where(u => u.Id == userId)
.Select(u => u.Role.Permissions)
.Cacheable()
.FirstOrDefaultAsync(ct);
Providers: Memory or Redis. For Amazon ElastiCache for Redis Serverless (TLS required), set Redis.UseSsl: true:
"SecondLevelCache": {
"Enabled": true,
"Options": {
"KeyPrefix": "EF_",
"Provider": "Redis",
"Redis": {
"ConnectionString": "my-cache.serverless.use1.cache.amazonaws.com:6379",
"TimeoutSeconds": 3,
"UseSsl": true
}
}
}
Seeding
With EnableSeeding: true, implement IDbContextInitializer<T>:
public class AppDbContextInitializer : IDbContextInitializer<AppDbContext>
{
public async Task SeedAsync(AppDbContext db, CancellationToken ct)
{
if (!await db.Roles.AnyAsync(ct))
{
db.Roles.Add(new Role
{
Id = Guid.CreateVersion7(),
Name = "Admin",
Permissions = AppPermissions.Definitions().ToArray(),
});
await db.SaveChangesAsync(ct);
}
}
}
Runs automatically on startup.
Migrations
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 # export all
dotnet-sub database sql --from V1 --to V2 # between two migrations
Authentication
Four schemes. Each is a toggle in appsettings.json. Enable any combination — they compose into a unified policy.
JWT Bearer
{
"Authentication": {
"JwtBearer": {
"Enabled": true,
"Options": {
"SecretKey": "YOUR_SECRET_KEY_AT_LEAST_32_CHARACTERS_LONG",
"Issuer": "https://myapp.com",
"Audience": "MyApp",
"Expiration": "1.00:00:00",
"RefreshExpiration": "7.00:00:00",
"ClockSkew": "00:02:00",
"RequireHttpsMetadata": true
}
}
}
}
Inject IJwtBearer:
// Access token only
var (token, sessionId, expiration) = jwt.CreateToken(user.Id);
// Access + refresh pair (needs IRefreshTokenStore)
var pair = await jwt.CreateTokenPairAsync(user.Id, ct);
// Refresh — old one is invalidated, new pair issued (rotation)
var refreshed = await jwt.RefreshAsync(oldRefreshToken, ct);
Implement IRefreshTokenStore to persist tokens (any backing store — DB, Redis, etc.):
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": {
"CookieName": ".MyApp.Auth",
"Expiration": "365.00:00:00",
"SlidingExpiration": true,
"Secure": true,
"HttpOnly": true,
"SameSite": "Lax"
}
}
}
}
await cookieAuth.SignInAsync(HttpContext, user.Id, ct);
await cookieAuth.SignOutAsync(HttpContext, ct);
Basic Auth
{ "Authentication": { "BasicAuthentication": { "Enabled": true, "Options": { "Realm": "MyApp" } } } }
Implement IBasicAuthValidator.
API Key Auth
{ "Authentication": { "ApiKeyAuthentication": { "Enabled": true, "Options": { "Realm": "MyApp", "KeyName": "X-API-KEY" } } } }
Implement IApiKeyValidator.
Multi-app auth
One backend, many clients (web/mobile/admin)? Pass an appId:
var (token, _, _) = jwt.CreateToken(user.Id, appId: "mobile");
// Later: ICurrentUser.AppId == "mobile"
Optionally validate app IDs by implementing IAppResolver.
Current user
Inject ICurrentUser anywhere:
_currentUser.UserId // Guid?
_currentUser.AppId // string?
_currentUser.Permissions // PermissionDefinition[]
Session validation
Revoke sessions server-side? Implement ISessionValidator — runs on every authenticated request.
Password hashing
IPasswordHasher or PasswordHasher.Instance — PBKDF2/HMAC-SHA256, 600,000 iterations:
var hash = hasher.HashPassword("s3cret");
bool ok = hasher.VerifyHashedPassword(hash, "s3cret", out bool needsRehash);
TOTP (2FA)
Inject ITotpProvider:
var secret = totp.GenerateSecret();
var qrUri = totp.GenerateQrCodeUri(secret, "user@example.com", "MyApp");
bool valid = totp.ValidateCode(secret, "123456");
Permissions
Define them once, type-safe, in a partial class:
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 adds Parse(code), TryParse, Definitions(), and extension methods — no hand-written code.
On endpoints
route.PermissionsAll(AppPermissions.Users_Create); // AND
route.PermissionsAny(AppPermissions.Users_View, AppPermissions.Users_ViewOwn); // OR
At runtime
if (_currentUser.Permissions.HasPermission(AppPermissions.Tickets_View)) { /* ... */ }
if (!_currentUser.Permissions.HasAnyPermission(p1, p2, p3))
return Failure<TicketDto>(403, "Forbidden");
Store in EF Core
Substratum handles JSON conversion automatically:
public sealed class Role : BaseEntity<Guid>
{
public required string Name { get; set; }
public required PermissionDefinition[] Permissions { get; set; }
// ↑ stored as JSON: ["users.create","users.view"]
}
Load per user
Implement IPermissionHydrator:
public class PermissionHydrator : IPermissionHydrator
{
public async Task<PermissionDefinition[]?> HydrateAsync(
IServiceProvider sp, string userId, CancellationToken ct)
{
var db = sp.GetRequiredService<AppDbContext>();
return await db.Users.AsNoTracking()
.Where(u => u.Id == Guid.Parse(userId))
.Select(u => u.Role.Permissions)
.FirstOrDefaultAsync(ct);
}
}
Loaded once per request, exposed via ICurrentUser.Permissions.
Events & Handlers
A built-in in-process event bus for domain events:
public sealed record TicketCreatedEvent(Guid TicketId, Guid UserId);
public sealed class TicketCreatedEventHandler : IEventHandler<TicketCreatedEvent>
{
public Task HandleAsync(TicketCreatedEvent e, CancellationToken ct)
{
// side effects — send email, notify, log
return Task.CompletedTask;
}
}
Publish by injecting IEventBus (the only event-bus type you depend on):
public CreateTicketEndpoint(IEventBus eventBus) => _eventBus = eventBus;
await _eventBus.PublishAsync(new TicketCreatedEvent(ticket.Id, user.Id), ct);
PublishAsync dispatches to in-process IEventHandler<T> handlers only — it does not push to SSE. To send a live update, use INotifier (see Live Events).
Handlers are discovered by the source generator and registered as scoped services.
Scaffold:
dotnet-sub new event --group Tickets --endpoint CreateTicket --name TicketCreated
Live Events (Real-time SSE)
Push server events to clients over Server-Sent Events — no SignalR, no WebSockets needed. Inject INotifier to send; clients connect with EventSource.
Enable
{
"Sse": {
"Enabled": true,
"Options": {
"Path": "/v1/sse",
"AllowAnonymous": false,
"ReconnectGracePeriodSeconds": 15,
"KeepAliveIntervalSeconds": 30,
"Provider": "Memory"
}
}
}
Set "AllowAnonymous": true to also accept unauthenticated clients. Use "Provider": "Redis" with a Redis.ConnectionString for multi-server clustering. For Amazon ElastiCache for Redis Serverless (TLS required), set Redis.UseSsl: true:
"Redis": {
"ConnectionString": "my-cache.serverless.use1.cache.amazonaws.com:6379",
"ChannelPrefix": "sse",
"UseSsl": true
}
Push with INotifier
Inject INotifier and pick the method that matches the audience. Every method takes (target, string eventName, object data, CancellationToken ct = default). Pushing is explicit — IEventBus.PublishAsync runs in-process handlers only and never pushes to SSE.
await notifier.ToUserAsync(userId, "Alert", new { Level = "warning" }, ct); // one user
await notifier.ToUsersAsync(userIds, "Alert", new { Level = "info" }, ct); // several users
await notifier.ToConnectionAsync(connectionId, "Hi", new { Message = "yo" }, ct); // one connection (works anonymous)
await notifier.ToChannelAsync("orders", "OrderCreated", new { OrderId = id }, ct); // channel subscribers
await notifier.ToRoleAsync("admin", "SystemAlert", new { Text = "Heads up" }, ct); // authenticated users in a role
await notifier.ToAllAsync("SystemMessage", new { Text = "Maintenance in 5m" }, ct); // everyone
| Method | Reaches |
|---|---|
ToUserAsync / ToUsersAsync |
every connection of one / several authenticated users |
ToConnectionAsync |
a single connection by id — works for anonymous clients |
ToChannelAsync |
every connection subscribed to the channel |
ToRoleAsync |
authenticated connections whose role claims include the role |
ToAllAsync |
every connected client |
Authenticated & anonymous
The endpoint serves both. By default it requires authentication; set Sse.Options.AllowAnonymous = true to also accept anonymous clients. Authenticated connections are addressable by user id, role, connection id, channel, and broadcast. Anonymous connections are addressable only by connection id, channel, and broadcast.
Channels & client-side
The connected event returns the connection id (and any restored channels). The client keeps it to manage channel subscriptions.
const sse = new EventSource('/v1/sse', { withCredentials: true });
let connectionId;
sse.addEventListener('connected', e => { connectionId = JSON.parse(e.data).connectionId; });
sse.addEventListener('OrderCreated', e => console.log('New order:', JSON.parse(e.data)));
await fetch('/v1/sse/subscribe', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connectionId, channels: ['orders', 'ticket:42'] }),
});
Unsubscribe via POST /v1/sse/unsubscribe { connectionId, channels }. When an authenticated client reconnects within the grace period, its channel subscriptions are restored automatically.
Online/offline tracking
Implement ISseConnectionObserver (works for authenticated and anonymous connections):
public class PresenceObserver : ISseConnectionObserver
{
public async Task OnConnectedAsync(SseConnectionContext c, CancellationToken ct)
{
if (c.UserId is { } userId && c.IsFirstConnection) { /* mark user online */ }
}
public async Task OnDisconnectedAsync(SseConnectionContext c, CancellationToken ct)
{
if (c.UserId is { } userId && c.IsLastConnection) { /* mark offline, set last-seen */ }
}
}
Inject SseMonitor (GetConnectedUsers, GetUserStatus, GetUserChannels) for presence introspection.
Channel authorization
Implement ISseChannelAuthorizer to gate which channels a connection may subscribe to (userId is null for anonymous):
public class ChannelAuthorizer : ISseChannelAuthorizer
{
public Task<bool> CanSubscribeAsync(Guid? userId, string channel, CancellationToken ct)
=> Task.FromResult(channel.StartsWith("public:") || userId is not null);
}
Background Jobs
Scaffold a job:
# Global scope (singleton job)
dotnet-sub new job --name SendDailyReport --scope Global --type Simple
# Feature-scoped with typed arguments
dotnet-sub new job --name ProcessOrder --scope Group --group Orders --type WithArgs
# Endpoint-scoped
dotnet-sub new job --name NotifyUser --scope Endpoint --group Users --endpoint CreateUser
Jobs integrate with your DI container and IDbContext.
Audit Logging
Same-transaction audit (recommended)
Implement IAuditableLog on your DbContext:
public class AppDbContext : DbContext, IAuditableLog
{
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
// ...
}
Every SaveChangesAsync writes audit records in the same transaction: entity type, entity ID, action (Create/Update/Delete), timestamp, user ID, and property-level old/new values.
Skip an entity or property with [AuditIgnore]
For high-write or noisy tables (telemetry events, raw imports, cache projections) decorate the entity class with [AuditIgnore] and the interceptor will skip it:
[AuditIgnore]
public sealed class TelemetryEvent : BaseEntity<Guid>
{
public required string Name { get; set; }
}
Apply [AuditIgnore] to a property to keep the entity audited but exclude sensitive fields (passwords, tokens, secrets) from the change log:
public sealed class User : BaseEntity<Guid>
{
public required string Email { get; set; }
[AuditIgnore] // value omitted from AuditLog.Changes
public required string PasswordHash { get; set; }
}
External audit store
For sending to ElasticSearch, a message queue, or a separate logging service, implement IAuditStore:
public class ExternalAuditStore : IAuditStore
{
public Task StoreAsync(IReadOnlyList<AuditLog> entries, CancellationToken ct)
{
// ship to Kafka, ELK, Datadog, ...
}
}
File Storage
One interface, three providers. Configure once, use everywhere.
{
"FileStorage": {
"Enabled": true,
"Options": {
"Provider": "Local",
"Container": "uploads",
"MaxFileSizeBytes": 52428800,
"AllowedExtensions": [".jpg", ".png", ".pdf", ".docx"]
}
}
}
Providers: Local, S3, AzureBlob (enable them under Aws.S3 / Azure.BlobStorage).
await storage.UploadAsync("documents/report.pdf", stream, "application/pdf", ct);
var data = await storage.DownloadAsync("documents/report.pdf", ct);
bool there = await storage.ExistsAsync("documents/report.pdf", ct);
await storage.DeleteAsync("documents/report.pdf", ct);
// Target a specific provider/container explicitly
await storage.UploadAsync(StorageProvider.S3, "my-bucket", "docs/x.pdf", stream, ct: ct);
Localization
- Drop
.resxfiles intoResources/:
Resources/SharedResource.en.resx
Resources/SharedResource.ar.resx
- Add a marker class:
public class SharedResource { }
- Configure the default:
{ "Localization": { "DefaultCulture": "en" } }
- Use it:
private readonly IStringLocalizer<SharedResource> _t;
return Success(_t["UserCreated"], data);
Supported cultures are detected automatically from your .resx filenames.
OpenAPI Docs
Docs are served at /scalar/v1 (Scalar UI) when enabled:
{
"OpenApi": {
"Enabled": true,
"Options": {
"Servers": [
{ "Url": "https://api.myapp.com", "Description": "Production" },
{ "Url": "https://localhost:5000", "Description": "Local" }
]
}
}
}
Per-endpoint summaries:
public partial class ListUsersSummary : EndpointSummary<ListUsersEndpoint>
{
protected override void Configure()
{
Description = "Lists all users with pagination support.";
Response<List<UserDto>>(200, "Users retrieved successfully");
Response(404, "No users found");
}
}
Split docs into groups (public / admin / internal)
public static partial class DocGroups : IDocGroupRegistry
{
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: route.DocGroup(DocGroups.AdminApi);
Groups with a permission are gated behind API-key or Basic-auth.
Firebase
Push notifications
{
"Firebase": {
"Messaging": {
"Enabled": true,
"Options": { "Credential": "BASE64_SERVICE_ACCOUNT_JSON" }
}
}
}
When enabled, the FirebaseMessaging client is available for injection.
App Check
{
"Firebase": {
"AppCheck": {
"Enabled": true,
"Options": {
"ProjectId": "your-project",
"ProjectNumber": "123456789"
}
}
}
}
bool valid = await appCheck.VerifyAppCheckTokenAsync(token, ct);
Infrastructure Toggles
All configured via appsettings.json. Every feature is a clean Enabled flag + Options section.
CORS
{
"Cors": {
"AllowedOrigins": ["https://app.myapp.com"],
"AllowedMethods": ["GET","POST","PUT","DELETE","PATCH"],
"AllowedHeaders": ["Content-Type","Authorization"],
"AllowCredentials": true,
"MaxAgeSeconds": 600
}
}
Rate Limiting
{
"RateLimiting": {
"Enabled": true,
"Options": {
"Provider": "Memory",
"GlobalPolicy": "Default",
"RejectionStatusCode": 429,
"Policies": {
"Default": { "Type": "FixedWindow", "PermitLimit": 100, "WindowSeconds": 60 },
"Strict": { "Type": "SlidingWindow", "PermitLimit": 10, "WindowSeconds": 60, "SegmentsPerWindow": 6 }
},
"Redis": {
"ConnectionString": "",
"UseSsl": false,
"KeyPrefix": ""
}
}
}
}
Types: FixedWindow, SlidingWindow, TokenBucket, Concurrency.
Per-endpoint: route.Options(o => o.RequireRateLimiting("Strict"));
Custom partitioning: implement IRateLimitPartitioner.
Providers:
Memory(default) — counters live in-process. Each node tracks its own counts. Use for single-node deployments.Redis— counters are stored in Redis and shared across all instances. Required for multi-node deployments where you want true global limits (e.g. login/MFA brute-force protection).Redis.ConnectionStringis optional — falls back to the globalRedissection when empty.SegmentsPerWindowis ignored under Redis (its sliding-window implementation uses a sorted-set, not segmented buckets).
Redis key layout: Substratum builds the partition key as {KeyPrefix}:{PolicyName}:{user-or-ip} and the underlying library wraps it as rl:<type>:{...} (where <type> is fw/sw/tb/cc). The policy name is always included so two policies of the same type sharing a user ID never collide. Set Redis.KeyPrefix (e.g. "myapp" or "myapp:prod") to isolate counters when multiple applications or environments share the same Redis.
Redis (shared connection)
A top-level Redis section provides one source of truth for the Redis connection so every Redis-backed feature (DistributedCache, DataProtection, Sse, EFCore SecondLevelCache) can share it. Enable it once and the per-feature Redis.ConnectionString / UseSsl become optional.
{
"Redis": {
"Enabled": true,
"Options": {
"ConnectionString": "localhost:6379",
"UseSsl": false
}
}
}
For Amazon ElastiCache for Redis Serverless (TLS required), set UseSsl: true.
Each feature still accepts its own Redis.ConnectionString for special cases (e.g. a dedicated Redis cluster for EF Core second-level cache). Resolution order: feature-level ConnectionString wins when set; otherwise falls back to the global Redis.Options.ConnectionString. Startup validation fails if a feature uses the Redis provider with neither option configured.
Distributed Cache
{
"DistributedCache": {
"Enabled": true,
"Options": {
"Provider": "Redis",
"Redis": { "ConnectionString": "", "InstanceName": "DC_", "UseSsl": false }
}
}
}
Redis.ConnectionString is optional. Leave empty to use the global Redis section, or set it to override with a dedicated connection.
Providers: Memory, Redis. Registers IDistributedCache.
Data Protection
ASP.NET Core Data Protection key storage. Used for anti-forgery tokens, auth cookies, and other protected payloads. Use Redis when running multiple instances so keys persist and are shared across nodes.
{
"DataProtection": {
"Enabled": true,
"Options": {
"Provider": "Redis",
"ApplicationName": "MyApp",
"Redis": { "ConnectionString": "", "Key": "DataProtection-Keys", "UseSsl": false }
}
}
}
Redis.ConnectionString is optional. Leave empty to use the global Redis section, or set it to override with a dedicated connection.
Providers: Memory (in-process, lost on restart, not shareable), Redis (persisted, shared across instances).
ApplicationName is optional — set it (and match it across instances) when multiple apps must share the same key ring.
Health Checks
{ "HealthChecks": { "Enabled": true, "Options": { "Path": "/healthz" } } }
DbContext health is included automatically. Add more:
return await SubstratumApp.RunAsync(args, options =>
{
options.HealthChecks.Options.HealthChecksBuilder = b =>
{
b.AddRedis("localhost:6379");
b.AddUrlGroup(new Uri("https://api.upstream.com/health"), "upstream");
};
});
Response Compression
{ "ResponseCompression": { "Enabled": true, "Options": { "EnableForHttps": true, "Providers": ["Brotli","Gzip"] } } }
Forwarded Headers (behind a proxy)
{ "ForwardedHeaders": { "Enabled": true, "Options": { "ForwardedHeaders": ["XForwardedFor","XForwardedProto"] } } }
⚠️ Behind a cloud load balancer or ingress? ASP.NET Core trusts forwarded headers from loopback proxies only by default. If your proxy is on a non-loopback address (Kubernetes ingress, Azure Container Apps, AWS ALB, App Service, etc.),
X-Forwarded-Foris silently discarded andRemoteIpAddressbecomes the proxy's IP — collapsing every client to one IP. This breaks per-IP rate limiting (all users share one bucket → intermittent429s) and any IP-based logic.Fix it one of two ways:
- You know the proxy's CIDR — list it in
KnownNetworks(most secure):{ "ForwardedHeaders": { "Enabled": true, "Options": { "KnownNetworks": ["10.0.0.0/8"] } } }- You can't enumerate the proxy IP — set
TrustAllProxiesto trust any forwarder (safe only when the app is reachable exclusively through a trusted proxy):{ "ForwardedHeaders": { "Enabled": true, "Options": { "TrustAllProxies": true } } }
Request Limits
{ "RequestLimits": { "MaxRequestBodySizeBytes": 52428800, "MaxMultipartBodyLengthBytes": 134217728 } }
Static Files
{ "StaticFiles": { "Enabled": true, "Options": { "RootPath": "wwwroot", "RequestPath": "" } } }
Error Handling
{ "ErrorHandling": { "IncludeExceptionDetails": false } }
Always set this to
falsein production.
Logging (Serilog)
Fully configured in appsettings.json. Built-in sensitive-data masking enricher:
{
"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
{
"Aws": { "S3": { "Enabled": true, "Options": { "Region": "us-east-1", "AccessKey": "...", "SecretKey": "..." } } },
"Azure": { "BlobStorage": { "Enabled": true, "Options": { "ConnectionString": "..." } } }
}
When enabled, IAmazonS3 and BlobServiceClient are directly injectable — use them or go through the unified IFileStorage.
Aws.S3.Options.RegionandAws.SecretsManager.Options.Regionare required when the matching feature is enabled — startup validation fails otherwise.
CLI Tool: dotnet-sub
dotnet tool install -g Substratum.Tools
Scaffolding
| Command | What it creates |
|---|---|
dotnet-sub new webapp MyApp |
Complete project (Program.cs, config, DbContext, permissions, sample endpoint) |
dotnet-sub new endpoint --group Users --name CreateUser --method Post --route /users --permission Users_Create |
Endpoint + request + response + validator + summary |
dotnet-sub new endpoint --group Users --name ListUsers --method Get --route /users --response-type PaginatedResult |
Paginated endpoint |
dotnet-sub new entity --name Product |
Entity + EF configuration |
dotnet-sub new event --group Tickets --endpoint CreateTicket --name TicketCreated |
Event type + handler |
dotnet-sub new job --name SendEmailJob --scope Global --type Simple |
Background job |
Endpoint options
| Flag | Values |
|---|---|
--method |
Get, Post, Put, Delete, Patch |
--endpoint-type |
Standard, Void, Stream |
--response-type |
SingleResult, PaginatedResult |
--use-result-wrapper |
Yes, No |
--permission |
A permission code (e.g., Users_Create) |
Job options
| Flag | Values |
|---|---|
--scope |
Global, Group, Endpoint |
--type |
Simple, WithArgs |
Database
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 # output migrations.sql
dotnet-sub database sql -o deploy.sql
dotnet-sub database sql --from V1 --to V2
Code migration (upgrading Substratum)
dotnet-sub migrate # rewrite v1.x code to current API
dotnet-sub migrate --dry-run # preview changes
dotnet-sub migrate --path ./src # target a specific directory
Handles base class renames, Configure() signature changes, method-prefixing, and more.
Recent breaking changes:
- Event bus — inject
IEventBusinstead of the concreteEventBus(now internal).dotnet-sub migraterewritesEventBusreferences toIEventBusautomatically. - SSE / Live Events redesign — the
LiveEventsconfig section is nowSse, and the API is a singleINotifierservice:LiveEventDispatcher→INotifier. Method mapping:Push(channel, …)→ToChannelAsync(channel, …),PushToUser(userId, …)→ToUserAsync(userId, …),Broadcast(…)→ToAllAsync(…). New:ToUsersAsync,ToConnectionAsync,ToRoleAsync. All methods are async and take a trailingCancellationToken.ILiveEventObserver→ISseConnectionObserver(now takes anSseConnectionContextwith a nullableUserId).ILiveEventAuthorizer→ISseChannelAuthorizer(CanSubscribeAsync(Guid? userId, string channel, ct)).ILiveEventand the generatedLiveEventsconstants class are removed — channels are plain strings.- SSE now supports anonymous connections (
Sse.Options.AllowAnonymous) and targets users, channels, roles, connections, or everyone. Channel subscription is byconnectionId(returned in theconnectedevent).dotnet-sub migrateflags the renamed types; method/signature changes need manual review.
- Reflection-free runtime — Substratum's runtime no longer uses reflection anywhere. Notable behavior changes:
INotifierpayloads and stream-endpoint items now serialize with the app's JSON options (web defaults → camelCase), matching endpoint responses. Previously they used STJ defaults (PascalCase) — update SSE clients that relied on PascalCase keys.PaginatedResult.CreateAsync(source, selector, ...)now keeps the selector inside the query, so EF translates it to SQL (fetching only projected columns) instead of materializing full entities and mapping in memory.- OpenAPI example summaries read the
Messagevia the newIResultEnvelopeinterface onResult<T>— examples that are notResult<T>instances fall back to the response description. [AuditIgnore]is discovered at compile time by the source generator (inheritance semantics preserved). Entities must be part of the compiled app project (or a project the generator runs on) for the attribute to be honored.
MCP Server (for AI Assistants)
Substratum.Mcp is a Model Context Protocol server that lets AI assistants like Claude, Cursor, and Windsurf understand, scaffold, and modify your Substratum project with expert-level fluency.
Install
dotnet tool install -g Substratum.Mcp
Register with Claude Code / Desktop
{
"mcpServers": {
"substratum": {
"command": "dotnet-sub-mcp"
}
}
}
No flags, no config files. When your MCP client exposes a workspace root, the server picks it up automatically — most tool calls no longer need a projectPath argument.
What you can ask your AI assistant to do
- "Scaffold a Products feature with CRUD endpoints" — the AI uses the
new_featureprompt which orchestrates entity → migration → endpoints → validators → build - "Add a permission for exporting invoices and gate the endpoint" —
scaffold kind=permissionplus endpoint update - "Analyze my project and tell me what's missing" — Roslyn scan flags missing FKs, indexes, naming violations
- "Upgrade this project to the latest Substratum" —
upgrade_projectprompt: analyse → dry-run → confirm → apply → build - "Design a backend for a multi-tenant SaaS" —
design_for_domainprompt produces a validated JSON design
Surface
The server exposes 13 tools, 8 resources, and 5 prompts. Every tool is annotated (ReadOnly, Destructive, Idempotent, OpenWorld) so clients can render safe permission UIs.
Tools
| Tool | Annotations | What it does |
|---|---|---|
scaffold |
Destructive |
Unified scaffolder. kind: project, entity, endpoint, event, job, service, doc_group, permission |
analyze_project |
ReadOnly, Idempotent |
Roslyn scan — returns entities, endpoints, permissions, DbContexts, events, jobs, enabled features |
design_validate |
ReadOnly, Idempotent |
Structural validation of a backend design; optional useSampling=true adds an LLM qualitative review |
generate_code |
Destructive |
Endpoint handler logic / validators / seeders. intent: logic, validation, seeder |
read_config |
ReadOnly, Idempotent |
Reads appsettings.json, optionally scoped to a section |
update_config |
Destructive, Idempotent |
Deep-merges a config object into a feature section |
db |
Destructive |
EF operations. action: migrate_add, update, sql. update asks for user confirmation via elicitation. |
upgrade |
Destructive |
Cross-version migration. action: analyze, endpoints, generics, renames. Dry-run by default; apply mode asks for confirmation. |
build |
Idempotent |
dotnet build with parsed diagnostics and streaming progress |
fs_list |
ReadOnly, Idempotent |
List project files filtered by extension |
fs_read |
ReadOnly, Idempotent |
Read a file (optional line range) |
fs_write |
Destructive, Idempotent |
Write to a file (creates parent dirs) |
run_command |
Destructive |
Allow-listed dotnet CLI commands (build, test, restore, publish, ef, tool, add, …) |
Every structured tool declares an outputSchema so clients can parse responses without guesswork.
Resources
Static content — load once, reference as often as the AI needs without burning tool round-trips:
| URI | What it is |
|---|---|
substratum://conventions |
Full coding conventions (25+ categories) |
substratum://schema/appsettings |
JSON Schema for appsettings.json |
substratum://skills |
Catalog of best-practice skill packs |
substratum://skills/{name} |
One skill pack. Names: database_design, entity_design, endpoint_design, endpoint_logic, validation, ef_core, linq, security |
substratum://guides |
Catalog of setup guides |
substratum://guides/{name} |
One setup guide. Names: overview, user-and-roles, permission-hydrator, session-validator, api-key-auth, basic-auth, database-seeder |
substratum://project/{path}/analysis |
The full analyze_project output for a project at {path} (URL-encoded). Addressable so agents can pin the analysis across turns. |
substratum://project/{path}/health |
Project-wide structural health: undefined permissions referenced by endpoints, declared-but-unused permissions, endpoints missing route/method/permission, missing DbContext or auth wiring. |
Resource templates use AllowedValues completions — your AI client can tab-complete valid skill/guide names.
Prompts
Orchestrated workflows — each expands into a detailed, ordered plan the AI executes end-to-end:
| Prompt | Does |
|---|---|
new_feature |
End-to-end: design → validate → scaffold entity → migration → endpoints → logic → validators → build |
full_crud |
Generates all 5 CRUD endpoints for an existing entity |
upgrade_project |
Safe upgrade: analyse → dry-run → confirm → apply → build |
review_design |
Reviews a proposed design against conventions + skills, with LLM qualitative feedback |
design_for_domain |
Produces a validated backend design from a natural-language domain description |
MCP capabilities used
The server takes full advantage of modern MCP features:
- Tool annotations — clients render correct permission UIs (read-only tools don't prompt; destructive tools do)
- Structured outputs — every tool returns typed content + declared
outputSchema - Progress notifications — long-running ops (
build,upgrade,db,scaffold) stream progress - Cancellation tokens — every async tool cancels cleanly when the client aborts
- Roots —
projectPathfalls back to the client's workspace root, so tool calls are shorter - Sampling —
design_validatecan ask the LLM for qualitative design feedback - Elicitation — destructive ops (
db action=update,upgrade dryRun=false) ask the user to confirm via the client's UI. Thescaffoldtool also elicits missing required string parameters (e.g.route,permission) instead of failing, and asks for confirmation before overwriting existing files. - Completions — resource template parameters (skill/guide names) offer tab-complete
Reference
Interfaces you implement
| Interface | When | Purpose |
|---|---|---|
IBasicAuthValidator |
When Basic Auth enabled | Validate username/password |
IApiKeyValidator |
When API Keys enabled | Validate API keys |
IRefreshTokenStore |
When using JWT refresh tokens | Persist & rotate refresh tokens |
ISessionValidator |
Optional | Runs on every authenticated request |
IPermissionHydrator |
Optional | Load user's permissions into claims |
IAppResolver |
Optional | Validate multi-app IDs |
IDbContextInitializer<T> |
Optional | Seed data at startup |
IAuditStore |
Optional | Ship audit entries externally |
ISseConnectionObserver |
Optional | React to SSE connect/disconnect |
ISseChannelAuthorizer |
Optional | Gate SSE channel subscriptions |
IRateLimitPartitioner |
Optional | Custom rate-limit partition keys |
IEventHandler<T> |
Per event | Handle domain events |
Services you inject
| Service | Available | Purpose |
|---|---|---|
ICurrentUser |
Always | User ID, app ID, permissions |
IPasswordHasher |
Always | Hash/verify passwords |
ITotpProvider |
Always | TOTP 2FA |
IEventBus |
Always | Publish domain events |
IJwtBearer |
When JWT enabled | Create/refresh JWTs |
ICookieAuth |
When Cookie auth enabled | Sign in/out |
IFileStorage |
When FileStorage enabled | Upload/download/delete |
INotifier |
When SSE enabled | Push SSE messages (user/channel/role/connection/all) |
SseMonitor |
When SSE enabled | Presence introspection (connected users, channels) |
IDistributedCache |
When DistributedCache enabled | Redis/memory caching |
IFirebaseAppCheck |
When AppCheck enabled | Verify App Check tokens |
IAmazonS3 |
When Aws.S3 enabled |
Raw S3 client |
BlobServiceClient |
When Azure.BlobStorage enabled |
Raw Azure Blob client |
Base classes
| Class | Use for |
|---|---|
Endpoint<TReq, TRes> |
Standard JSON endpoint |
Endpoint<TReq> |
Void endpoint |
StreamEndpoint<TReq> / StreamEndpoint<TReq, TRes> |
Server-Sent Events |
BaseEntity<T> |
Domain entity (soft-delete + timestamps) |
Validator<T> |
FluentValidation validator |
EndpointSummary<TEndpoint> |
OpenAPI metadata |
Data types
| Type | Use for |
|---|---|
Result<T> |
Uniform success/error envelope |
PaginatedResult<T> |
Paginated list response |
Unit |
Empty response body |
PermissionDefinition |
Typed permission with group metadata |
DocGroupDefinition |
OpenAPI doc group |
Full appsettings.json Reference
Every option, every default — mirrors substratum.schema.json exactly. Defaults below are the
production-safe ones from the Options classes; the dotnet-sub new webapp template ships with
development-friendly variants of these same shapes.
{
"$schema": "./substratum.schema.json",
"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" },
{ "Name": "SecretKey" }
]
}
}
}
],
"Properties": { "Application": "MyApp" },
"WriteTo": [{ "Name": "Console" }]
},
"Cors": {
"AllowedOrigins": [],
"AllowedMethods": [],
"AllowedHeaders": [],
"AllowCredentials": true,
"MaxAgeSeconds": 600
},
"Authentication": {
"JwtBearer": {
"Enabled": false,
"Options": {
"SecretKey": "YOUR_SECRET_KEY_AT_LEAST_64_CHARACTERS_LONG_CHANGE_FOR_PRODUCTION_USE",
"Issuer": "https://api.myapp.com",
"Audience": "MyApp",
"Expiration": "1.00:00:00",
"RefreshExpiration": "7.00:00:00",
"ClockSkew": "00:02:00",
"RequireHttpsMetadata": true
}
},
"Cookie": {
"Enabled": false,
"Options": {
"Scheme": "Cookies",
"CookieName": ".MyApp.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": false,
"Logging": {
"EnableDetailedErrors": false,
"EnableSensitiveDataLogging": false
},
"RetryPolicy": {
"Enabled": false,
"Options": { "MaxRetryCount": 3, "MaxRetryDelaySeconds": 5 }
},
"SecondLevelCache": {
"Enabled": false,
"Options": {
"KeyPrefix": "EF_",
"Provider": "Memory",
"Redis": { "ConnectionString": "", "TimeoutSeconds": 3, "UseSsl": false }
}
}
}
},
"ErrorHandling": { "IncludeExceptionDetails": false },
"Localization": { "DefaultCulture": "en" },
"OpenApi": {
"Enabled": false,
"Options": {
"Servers": [
{ "Url": "https://api.myapp.com", "Description": "Production" }
]
}
},
"StaticFiles": {
"Enabled": false,
"Options": {
"RootPath": "wwwroot",
"RequestPath": "",
"ContentTypeMappings": {}
}
},
"HealthChecks": {
"Enabled": false,
"Options": { "Path": "/healthz" }
},
"Aws": {
"S3": {
"Enabled": false,
"Options": {
"Endpoint": null,
"ForcePathStyle": false,
"Region": "us-east-1",
"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_AZURE_BLOB_STORAGE_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": null
}
}
},
"DistributedCache": {
"Enabled": false,
"Options": {
"Provider": "Memory",
"Redis": { "ConnectionString": "", "InstanceName": "", "UseSsl": false }
}
},
"ResponseCompression": {
"Enabled": true,
"Options": {
"EnableForHttps": true,
"Providers": ["Brotli", "Gzip"],
"MimeTypes": []
}
},
"ForwardedHeaders": {
"Enabled": false,
"Options": {
"ForwardedHeaders": ["XForwardedFor", "XForwardedProto"],
"KnownProxies": [],
"KnownNetworks": [],
"TrustAllProxies": false
}
},
"RequestLimits": {
"MaxRequestBodySizeBytes": 52428800,
"MaxMultipartBodyLengthBytes": 134217728
},
"FileStorage": {
"Enabled": false,
"Options": {
"Provider": "Local",
"Container": "uploads",
"MaxFileSizeBytes": 52428800,
"AllowedExtensions": []
}
},
"RateLimiting": {
"Enabled": false,
"Options": {
"Provider": "Memory",
"GlobalPolicy": "Default",
"RejectionStatusCode": 429,
"Policies": {
"Default": {
"Type": "FixedWindow",
"PermitLimit": 100,
"WindowSeconds": 60,
"QueueLimit": 0
}
},
"Redis": {
"ConnectionString": "",
"UseSsl": false,
"KeyPrefix": ""
}
}
},
"Sse": {
"Enabled": false,
"Options": {
"Path": "/v1/sse",
"AllowAnonymous": false,
"ReconnectGracePeriodSeconds": 15,
"KeepAliveIntervalSeconds": 30,
"CleanupIntervalSeconds": 10,
"Provider": "Memory",
"Redis": { "ConnectionString": "", "ChannelPrefix": "sse", "UseSsl": false }
}
}
}
License
MIT — do anything you want with it.
Built for .NET developers who'd rather ship features than wire up middleware.
Learn more about Target Frameworks and .NET Standard.
-
.NETStandard 2.0
- Scriban (>= 7.0.5)
- System.Text.Json (>= 10.0.8)
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 |
|---|---|---|
| 3.0.0-alpha.1 | 49 | 6/11/2026 |
| 2.60.0 | 117 | 5/29/2026 |
| 2.50.1 | 99 | 5/26/2026 |
| 2.50.0 | 99 | 5/25/2026 |
| 2.40.0 | 100 | 5/25/2026 |
| 2.30.0 | 102 | 5/23/2026 |
| 2.26.1 | 137 | 5/15/2026 |
| 2.26.0 | 102 | 5/14/2026 |
| 2.25.0 | 111 | 5/10/2026 |
| 2.24.0 | 153 | 4/17/2026 |
| 2.23.0 | 103 | 4/17/2026 |
| 2.22.0 | 113 | 4/17/2026 |
| 2.21.0 | 99 | 4/17/2026 |
| 2.20.0 | 98 | 4/17/2026 |
| 2.19.0 | 103 | 4/17/2026 |
| 2.18.0 | 98 | 4/17/2026 |
| 2.15.0 | 108 | 4/11/2026 |
| 2.14.0 | 96 | 4/11/2026 |
| 2.12.0 | 103 | 4/11/2026 |
| 2.10.0 | 103 | 4/10/2026 |