![]() |
VOOZH | about |
dotnet add package Substratum --version 2.60.0
NuGet\Install-Package Substratum -Version 2.60.0
<PackageReference Include="Substratum" Version="2.60.0" />
<PackageVersion Include="Substratum" Version="2.60.0" />Directory.Packages.props
<PackageReference Include="Substratum" />Project file
paket add Substratum --version 2.60.0
#r "nuget: Substratum, 2.60.0"
#:package Substratum@2.60.0
#addin nuget:?package=Substratum&version=2.60.0Install as a Cake Addin
#tool nuget:?package=Substratum&version=2.60.0Install as a Cake Tool
The batteries-included backend framework for ASP.NET Core. Ship a production-grade API in minutes â not weeks.
ð Substratum
ð Generator
ð Tools
ð MCP
ð .NET 10
ð MIT License
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.
| 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.
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:
~0.1â0.2 KB difference per request).Endpoint<TReq, TRes> class-based abstraction pays no runtime cost â it's all source-generated.<details> <summary>Benchmark setup</summary>
HttpClient round-tripcd benchmarks/Substratum.Benchmarks && dotnet run -c Release -- --filter '*Throughput*'--filter for the interactive menu.</details>
dotnet-sub| 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 |
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.
dotnet run
http://localhost:5000http://localhost:5000/scalar/v1http://localhost:5000/healthzdotnet-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.
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.
If you need to register extra services:
return await SubstratumApp.RunAsync(args, options =>
{
options.Services.AddSingleton<IMyService, MyService>();
options.Services.AddHttpClient<IPaymentsClient, PaymentsClient>();
});
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) |
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
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
}
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 });
}
}
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);
}
}
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;
}
}
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);
}
}
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());
}
}
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.
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
{
"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.
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.
Tag each one:
[DbContextName("Default")]
public class AppDbContext : DbContext { /* ... */ }
[DbContextName("Analytics")]
public class AnalyticsDbContext : DbContext { /* ... */ }
{
"EntityFramework": {
"Default": { "Provider": "Npgsql", "ConnectionString": "..." },
"Analytics": { "Provider": "SqlServer", "ConnectionString": "..." }
}
}
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
}
}
}
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.
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
Four schemes. Each is a toggle in appsettings.json. Enable any combination â they compose into a unified policy.
{
"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);
}
{
"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);
{ "Authentication": { "BasicAuthentication": { "Enabled": true, "Options": { "Realm": "MyApp" } } } }
Implement IBasicAuthValidator.
{ "Authentication": { "ApiKeyAuthentication": { "Enabled": true, "Options": { "Realm": "MyApp", "KeyName": "X-API-KEY" } } } }
Implement IApiKeyValidator.
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.
Inject ICurrentUser anywhere:
_currentUser.UserId // Guid?
_currentUser.AppId // string?
_currentUser.Permissions // PermissionDefinition[]
Revoke sessions server-side? Implement ISessionValidator â runs on every authenticated request.
IPasswordHasher or PasswordHasher.Instance â PBKDF2/HMAC-SHA256, 600,000 iterations:
var hash = hasher.HashPassword("s3cret");
bool ok = hasher.VerifyHashedPassword(hash, "s3cret", out bool needsRehash);
Inject ITotpProvider:
var secret = totp.GenerateSecret();
var qrUri = totp.GenerateQrCodeUri(secret, "user@example.com", "MyApp");
bool valid = totp.ValidateCode(secret, "123456");
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.
route.PermissionsAll(AppPermissions.Users_Create); // AND
route.PermissionsAny(AppPermissions.Users_View, AppPermissions.Users_ViewOwn); // OR
if (_currentUser.Permissions.HasPermission(AppPermissions.Tickets_View)) { /* ... */ }
if (!_currentUser.Permissions.HasAnyPermission(p1, p2, p3))
return Failure<TicketDto>(403, "Forbidden");
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"]
}
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.
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
Push server events to clients over Server-Sent Events â no SignalR, no WebSockets needed.
{
"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. For Amazon ElastiCache for Redis Serverless (TLS required), set Redis.UseSsl: true:
"Redis": {
"ConnectionString": "my-cache.serverless.use1.cache.amazonaws.com:6379",
"ChannelPrefix": "live-events",
"UseSsl": true
}
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
Inject LiveEventDispatcher:
_live.Push("Notification", new { Message = "Hello" });
_live.PushToUser(userId, "Alert", new { Level = "warning" });
_live.Broadcast("SystemMessage", new { Text = "Maintenance in 5m" });
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.
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 */ }
}
}
Implement ILiveEventAuthorizer to gate who can subscribe to which events.
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.
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.
[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; }
}
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, ...
}
}
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);
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.
.resx files into Resources/:Resources/SharedResource.en.resx
Resources/SharedResource.ar.resx
public class SharedResource { }
{ "Localization": { "DefaultCulture": "en" } }
private readonly IStringLocalizer<SharedResource> _t;
return Success(_t["UserCreated"], data);
Supported cultures are detected automatically from your .resx filenames.
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");
}
}
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": {
"Messaging": {
"Enabled": true,
"Options": { "Credential": "BASE64_SERVICE_ACCOUNT_JSON" }
}
}
}
When enabled, the FirebaseMessaging client is available for injection.
{
"Firebase": {
"AppCheck": {
"Enabled": true,
"Options": {
"ProjectId": "your-project",
"ProjectNumber": "123456789"
}
}
}
}
bool valid = await appCheck.VerifyAppCheckTokenAsync(token, ct);
All configured via appsettings.json. Every feature is a clean Enabled flag + Options section.
{
"Cors": {
"AllowedOrigins": ["https://app.myapp.com"],
"AllowedMethods": ["GET","POST","PUT","DELETE","PATCH"],
"AllowedHeaders": ["Content-Type","Authorization"],
"AllowCredentials": true,
"MaxAgeSeconds": 600
}
}
{
"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.ConnectionString is optional â falls back to the global Redis section when empty. SegmentsPerWindow is 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.
A top-level Redis section provides one source of truth for the Redis connection so every Redis-backed feature (DistributedCache, DataProtection, LiveEvents, 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.
{
"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.
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.
{ "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");
};
});
{ "ResponseCompression": { "Enabled": true, "Options": { "EnableForHttps": true, "Providers": ["Brotli","Gzip"] } } }
{ "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 } } }
{ "RequestLimits": { "MaxRequestBodySizeBytes": 52428800, "MaxMultipartBodyLengthBytes": 134217728 } }
{ "StaticFiles": { "Enabled": true, "Options": { "RootPath": "wwwroot", "RequestPath": "" } } }
{ "ErrorHandling": { "IncludeExceptionDetails": false } }
Always set this to
falsein production.
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" }]
}
}
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"]
}
}
}
}
{
"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.
dotnet-subdotnet tool install -g Substratum.Tools
| 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 |
| 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) |
| Flag | Values |
|---|---|
--scope |
Global, Group, Endpoint |
--type |
Simple, WithArgs |
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
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.
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.
dotnet tool install -g Substratum.Mcp
{
"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.
new_feature prompt which orchestrates entity â migration â endpoints â validators â buildscaffold kind=permission plus endpoint updateupgrade_project prompt: analyse â dry-run â confirm â apply â builddesign_for_domain prompt produces a validated JSON designThe server exposes 13 tools, 8 resources, and 5 prompts. Every tool is annotated (ReadOnly, Destructive, Idempotent, OpenWorld) so clients can render safe permission UIs.
| 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.
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.
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 |
The server takes full advantage of modern MCP features:
outputSchemabuild, upgrade, db, scaffold) stream progressprojectPath falls back to the client's workspace root, so tool calls are shorterdesign_validate can ask the LLM for qualitative design feedbackdb action=update, upgrade dryRun=false) ask the user to confirm via the client's UI. The scaffold tool also elicits missing required string parameters (e.g. route, permission) instead of failing, and asks for confirmation before overwriting existing files.| 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 |
| 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 |
| 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 |
| 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 |
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": ""
}
}
},
"LiveEvents": {
"Enabled": false,
"Options": {
"Path": "/v1/live-events",
"ReconnectGracePeriodSeconds": 15,
"KeepAliveIntervalSeconds": 30,
"CleanupIntervalSeconds": 10,
"Provider": "Memory",
"Redis": { "ConnectionString": "", "ChannelPrefix": "live-events", "UseSsl": false }
}
}
}
â 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 net10.0 is compatible. net10.0-android net10.0-android was computed. net10.0-browser net10.0-browser was computed. net10.0-ios net10.0-ios was computed. net10.0-maccatalyst net10.0-maccatalyst was computed. net10.0-macos net10.0-macos was computed. net10.0-tvos net10.0-tvos was computed. net10.0-windows net10.0-windows was computed. |
This package is not used by any NuGet packages.
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.0.0-alpha.1 | 47 | 6/11/2026 |
| 2.60.0 | 115 | 5/29/2026 |
| 2.50.1 | 91 | 5/26/2026 |
| 2.50.0 | 96 | 5/25/2026 |
| 2.40.0 | 97 | 5/25/2026 |
| 2.30.0 | 94 | 5/23/2026 |
| 2.26.1 | 129 | 5/15/2026 |
| 2.26.0 | 99 | 5/14/2026 |
| 2.25.0 | 106 | 5/10/2026 |
| 2.24.0 | 149 | 4/17/2026 |
| 2.23.0 | 113 | 4/17/2026 |
| 2.22.0 | 107 | 4/17/2026 |
| 2.21.0 | 116 | 4/17/2026 |
| 2.20.0 | 105 | 4/17/2026 |
| 2.19.0 | 103 | 4/17/2026 |
| 2.18.0 | 103 | 4/17/2026 |
| 2.15.0 | 115 | 4/11/2026 |
| 2.14.0 | 99 | 4/11/2026 |
| 2.12.0 | 111 | 4/11/2026 |
| 2.10.0 | 106 | 4/10/2026 |