Substratum 2.24.0
dotnet add package Substratum --version 2.24.0
NuGet\Install-Package Substratum -Version 2.24.0
<PackageReference Include="Substratum" Version="2.24.0" />
<PackageVersion Include="Substratum" Version="2.24.0" />
<PackageReference Include="Substratum" />
paket add Substratum --version 2.24.0
#r "nuget: Substratum, 2.24.0"
#:package Substratum@2.24.0
#addin nuget:?package=Substratum&version=2.24.0
#tool nuget:?package=Substratum&version=2.24.0

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.
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.19.0" /> |
Substratum.Generator |
Source generators (zero reflection, AOT-friendly) | <PackageReference Include="Substratum.Generator" Version="2.19.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" }
}
}
}
}
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.
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:
await _eventBus.PublishAsync(new TicketCreatedEvent(ticket.Id, user.Id), ct);
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.
Enable
{
"LiveEvents": {
"Enabled": true,
"Options": {
"Path": "/v1/live-events",
"ReconnectGracePeriodSeconds": 15,
"KeepAliveIntervalSeconds": 30,
"Provider": "Memory"
}
}
}
Use "Provider": "Redis" with a Redis.ConnectionString for multi-server clustering.
Auto-broadcast events
Mark any event with ILiveEvent — publishing it fans out to subscribers automatically:
public sealed record OrderCreatedEvent(Guid OrderId, string Customer) : ILiveEvent;
await _eventBus.PublishAsync(new OrderCreatedEvent(order.Id, order.Customer), ct);
// All subscribed SSE clients receive it
Manual push
Inject LiveEventDispatcher:
_live.Push("Notification", new { Message = "Hello" });
_live.PushToUser(userId, "Alert", new { Level = "warning" });
_live.Broadcast("SystemMessage", new { Text = "Maintenance in 5m" });
Client-side
const sse = new EventSource('/v1/live-events', { withCredentials: true });
sse.addEventListener('OrderCreatedEvent', e => {
console.log('New order:', JSON.parse(e.data));
});
await fetch('/v1/live-events/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events: ['OrderCreatedEvent', 'ChatMessage'] }),
});
Reconnects within the grace period restore subscriptions automatically.
Online/offline tracking
Implement ILiveEventObserver:
public class PresenceObserver : ILiveEventObserver
{
public async Task OnConnectedAsync(Guid userId, bool isFirstConnection, CancellationToken ct)
{
if (isFirstConnection) { /* mark user online */ }
}
public async Task OnDisconnectedAsync(Guid userId, bool isLastConnection, CancellationToken ct)
{
if (isLastConnection) { /* mark user offline, set last-seen */ }
}
}
Authorization
Implement ILiveEventAuthorizer to gate who can subscribe to which events.
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.
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<AuditEntry> 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);
Image Processing
IImageService is always available — resize, WebP-convert, strip metadata, generate BlurHash:
using var r = await images.ProcessAsync(upload.OpenReadStream(), new ImageProcessingOptions
{
MaxWidth = 800, MaxHeight = 600, Quality = 80
});
await storage.UploadAsync("images/photo.webp", r.Content, r.ContentType, ct);
// BlurHash placeholder for progressive loading
string hash = await images.BlurHashAsync(upload.OpenReadStream());
// "LEHV6nWB2yk8pyo0adR*.7kCMdnj"
Defaults: 512×512, quality 70, WebP, metadata stripped.
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": {
"GlobalPolicy": "Default",
"RejectionStatusCode": 429,
"Policies": {
"Default": { "Type": "FixedWindow", "PermitLimit": 100, "WindowSeconds": 60 },
"Strict": { "Type": "SlidingWindow", "PermitLimit": 10, "WindowSeconds": 60, "SegmentsPerWindow": 6 }
}
}
}
}
Types: FixedWindow, SlidingWindow, TokenBucket, Concurrency.
Per-endpoint: route.Options(o => o.RequireRateLimiting("Strict"));
Custom partitioning: implement IRateLimitPartitioner.
Distributed Cache
{
"DistributedCache": {
"Enabled": true,
"Options": {
"Provider": "Redis",
"Redis": { "ConnectionString": "localhost:6379", "InstanceName": "DC_" }
}
}
}
Providers: Memory, Redis. Registers IDistributedCache.
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"] } } }
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.
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.
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, 6 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 |
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 - 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 |
ILiveEventObserver |
Optional | React to user connect/disconnect |
ILiveEventAuthorizer |
Optional | Gate Live Events 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 |
IImageService |
Always | Resize / WebP / BlurHash |
EventBus |
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 |
LiveEventDispatcher |
When Live Events enabled | Push SSE messages |
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:
{
"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": "1.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": false },
"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"]
}
}
},
"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" }
}
}
}
License
MIT — do anything you want with it.
Built for .NET developers who'd rather ship features than wire up middleware.
| 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.2)
- 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.6)
- Microsoft.AspNetCore.OpenApi (>= 10.0.6)
- Microsoft.EntityFrameworkCore (>= 10.0.6)
- Microsoft.EntityFrameworkCore.InMemory (>= 10.0.6)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.6)
- Microsoft.EntityFrameworkCore.Sqlite (>= 10.0.6)
- Microsoft.EntityFrameworkCore.SqlServer (>= 10.0.6)
- Microsoft.Extensions.Caching.StackExchangeRedis (>= 10.0.6)
- Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore (>= 10.0.6)
- Microting.EntityFrameworkCore.MySql (>= 10.0.5)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 10.0.1)
- Otp.NET (>= 1.4.1)
- Scalar.AspNetCore (>= 2.14.0)
- 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.24.0 | 48 | 4/17/2026 |
| 2.23.0 | 56 | 4/17/2026 |
| 2.22.0 | 50 | 4/17/2026 |
| 2.21.0 | 56 | 4/17/2026 |
| 2.20.0 | 45 | 4/17/2026 |
| 2.19.0 | 64 | 4/17/2026 |
| 2.18.0 | 61 | 4/17/2026 |
| 2.15.0 | 91 | 4/11/2026 |
| 2.14.0 | 81 | 4/11/2026 |
| 2.12.0 | 91 | 4/11/2026 |
| 2.10.0 | 88 | 4/10/2026 |
| 2.9.0 | 89 | 4/7/2026 |
| 2.8.0 | 92 | 4/3/2026 |
| 2.6.0 | 117 | 3/28/2026 |
| 2.5.1 | 92 | 3/28/2026 |
| 2.5.0 | 93 | 3/27/2026 |
| 2.4.0 | 101 | 3/27/2026 |
| 2.3.3 | 98 | 3/27/2026 |
| 2.3.1 | 86 | 3/27/2026 |
| 2.1.1 | 94 | 3/26/2026 |