Substratum 1.0.0-beta.122
dotnet add package Substratum --version 1.0.0-beta.122
NuGet\Install-Package Substratum -Version 1.0.0-beta.122
<PackageReference Include="Substratum" Version="1.0.0-beta.122" />
<PackageVersion Include="Substratum" Version="1.0.0-beta.122" />
<PackageReference Include="Substratum" />
paket add Substratum --version 1.0.0-beta.122
#r "nuget: Substratum, 1.0.0-beta.122"
#:package Substratum@1.0.0-beta.122
#addin nuget:?package=Substratum&version=1.0.0-beta.122&prerelease
#tool nuget:?package=Substratum&version=1.0.0-beta.122&prerelease
Substratum
Substratum is an opinionated, production-grade application framework built on ASP.NET Core and FastEndpoints. It eliminates the boilerplate required to bootstrap a modern web API — authentication, authorization, database, caching, logging, OpenAPI docs, cloud storage, spreadsheets, and push notifications are all pre-wired and ready to go.
Write your business logic. Substratum handles the rest.
Table of Contents
- Packages
- Quick Start
- How It Works
- Configuration
- Core Library
- Authentication
- Permissions
- Document Groups
- Entity Framework
- OpenAPI Documentation
- Localization
- Spreadsheet
- Spreadsheet Quick Start
- Attributes Reference
- Spreadsheet Enums
- Excel Export
- CSV Export
- Multi-Sheet Workbook
- Template Engine
- Import
- Sheet Overrides
- Protection
- DataTable Support
- Streaming
- SpreadsheetFile
- ISpreadsheet API Reference
- IWorkbookBuilder API Reference
- ITemplateBuilder API Reference
- Spreadsheet Source Generator Diagnostics
- Spreadsheet Configuration
- Image Processing
- Cloud Storage
- Firebase
- Infrastructure
- Background Jobs
- Audit Logging
- Encryption
- Webhooks
- Source Generators (Substratum.Generator)
- CLI Tools (dotnet-sub)
- All Contracts and Interfaces
- Full Configuration Reference
- License
Packages
Install all three into your project:
<PackageReference Include="Substratum" Version="1.0.0-beta.96" />
<PackageReference Include="Substratum.Generator" Version="1.0.0-beta.96"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
Install the CLI tool globally:
dotnet tool install --global Substratum.Tools
Quick Start
1. Create a new project
dotnet-sub new webapp MyApp
This scaffolds a complete project with Program.cs, appsettings.json, security, entities, and a sample endpoint.
2. Or add to an existing project
Create a Program.cs file:
return await Substratum.SubstratumApp.RunAsync(args);
That's it. One line of code boots your entire application. The source generators wire everything else automatically.
3. Add your appsettings.json
{
"Authentication": {
"JwtBearer": {
"Enabled": true,
"Options": {
"SecretKey": "YOUR_SECRET_KEY_MUST_BE_AT_LEAST_32_CHARACTERS_LONG",
"Issuer": "http://localhost:5000",
"Audience": "MyApp",
"Expiration": "1.00:00:00"
}
}
},
"EntityFramework": {
"Default": {
"Provider": "Npgsql",
"ConnectionString": "Host=localhost;Database=mydb;Username=postgres;Password=password"
}
}
}
4. Create your first endpoint
using Substratum;
public class GetUsersEndpoint : BaseEndpoint<GetUsersRequest, List<UserDto>>
{
public override void Configure()
{
Get("/api/users");
AllowAnonymous();
}
public override Task<Result<List<UserDto>>> ExecuteAsync(GetUsersRequest req, CancellationToken ct)
{
var users = new List<UserDto> { new() { Name = "John" } };
return Task.FromResult(Success("Users retrieved", users));
}
}
How It Works
The FeatureOptions Pattern
Most features in Substratum follow the FeatureOptions pattern. This means each feature can be enabled or disabled from your configuration:
public sealed class FeatureOptions<TOptions> where TOptions : class, new()
{
public bool Enabled { get; set; } // Toggles the feature on/off
public TOptions Options { get; init; } // Feature-specific settings
}
In your appsettings.json, this looks like:
{
"FeatureName": {
"Enabled": true,
"Options": {
"Setting1": "value",
"Setting2": 42
}
}
}
Features using this pattern: OpenApi, StaticFiles, HealthChecks, Minio, DistributedCache, ResponseCompression, ForwardedHeaders, FileStorage, RateLimiting, Spreadsheet, AWS S3, AWS SecretsManager, Azure BlobStorage, Firebase Messaging, Firebase AppCheck.
Features always active (no Enabled toggle): Cors, Authentication, EntityFramework, ErrorHandling, Localization, RequestLimits.
Source Generators Overview
Substratum uses 8 incremental source generators that analyze your code at compile time and generate boilerplate automatically. This means:
- Zero reflection at runtime — all type discovery happens at compile time
- Better performance — no startup scanning of assemblies
- AOT-compatible — works with Native AOT and trimming
- Compile-time validation — errors are caught before your app runs
The source generators handle: app bootstrapping, endpoint discovery, service registration, permission registries, localization, endpoint summaries, document groups, and spreadsheet metadata.
Configuration
appsettings.json (Recommended)
The recommended way to configure Substratum is through appsettings.json. All options are bound automatically from the configuration:
{
"ServerEnvironment": "Development",
"Cors": { ... },
"Authentication": { ... },
"EntityFramework": { ... },
"ErrorHandling": { ... },
"Localization": { ... },
"OpenApi": { ... },
"StaticFiles": { ... },
"HealthChecks": { ... },
"Minio": { ... },
"Aws": { ... },
"Azure": { ... },
"Firebase": { ... },
"DistributedCache": { ... },
"ResponseCompression": { ... },
"ForwardedHeaders": { ... },
"RequestLimits": { ... },
"FileStorage": { ... },
"RateLimiting": { ... },
"Spreadsheet": { ... }
}
C# Configuration
You can also configure options programmatically:
return await SubstratumApp.RunAsync(args, options =>
{
options.ServerEnvironment = ServerEnvironment.Development;
options.Authentication.JwtBearer.Enabled = true;
options.Authentication.JwtBearer.Options.SecretKey = "my-secret-key";
// Register additional services
options.Services.AddSingleton<IMyService, MyService>();
});
The configure callback runs after appsettings.json is loaded, so you can override any JSON setting from code. You can also register additional DI services through options.Services.
Server Environment
public enum ServerEnvironment
{
Development,
Staging,
UAT,
Production
}
Set it in appsettings.json:
{
"ServerEnvironment": "Development"
}
Use in code:
if (options.ServerEnvironment.IsDevelopment())
{
// Dev-only logic
}
Extension methods: IsDevelopment(), IsStaging(), IsUAT(), IsProduction().
Core Library
Endpoints
All endpoints extend BaseEndpoint<TRequest, TResponse>, which provides a structured API built on FastEndpoints:
public abstract class BaseEndpoint<TRequest, TResponse> : Endpoint<TRequest, Result<TResponse>>
where TRequest : notnull
{
// You must implement these two methods:
public abstract override void Configure();
public abstract override Task<Result<TResponse>> ExecuteAsync(TRequest req, CancellationToken ct);
// Helper methods available in your endpoints:
protected Result<TResponse> Success(string message, TResponse data);
protected Result<TResponse> Failure(int statusCode, string message, IReadOnlyList<string>? errors = null);
// Permission methods (type-safe — accept PermissionDefinition objects):
protected void PermissionsAny(params PermissionDefinition[] permissions);
protected void PermissionsAll(params PermissionDefinition[] permissions);
// Document group assignment:
protected void DocGroup(params DocGroupDefinition[] groups);
}
Full Endpoint Example
Every Substratum endpoint consists of up to 6 files:
1. Request class — input data:
public class ListUsersRequest
{
public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 10;
}
2. Response class — output data:
public class ListUsersResponse
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
3. Validator — input validation using FluentValidation:
public class ListUsersRequestValidator : Validator<ListUsersRequest>
{
public ListUsersRequestValidator()
{
RuleFor(x => x.PageNumber).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 100);
}
}
4. Serializer context — for AOT-compatible JSON serialization:
[JsonSerializable(typeof(ListUsersRequest))]
[JsonSerializable(typeof(Result<PaginatedResult<ListUsersResponse>>))]
[JsonSerializable(typeof(Result<Unit>))]
public partial class ListUsersSerializerContext : JsonSerializerContext { }
5. Summary — OpenAPI documentation with localization:
public partial class ListUsersSummary : SubstratumEndpointSummary
{
protected override void Configure(IStringLocalizer localizer)
{
Description = "Lists all users with pagination.";
}
}
6. Endpoint — the actual handler:
public class ListUsersEndpoint : BaseEndpoint<ListUsersRequest, PaginatedResult<ListUsersResponse>>
{
private readonly AppDbContext _db;
private readonly IStringLocalizer<SharedResource> _localizer;
public ListUsersEndpoint(AppDbContext db, IStringLocalizer<SharedResource> localizer)
{
_db = db;
_localizer = localizer;
}
public override void Configure()
{
Version(1);
Get("/api/v{version}/users");
PermissionsAll(AppPermissions.Users_List);
SerializerContext<ListUsersSerializerContext>();
Summary(new ListUsersSummary(_localizer));
}
public override async Task<Result<PaginatedResult<ListUsersResponse>>> ExecuteAsync(
ListUsersRequest req, CancellationToken ct)
{
var result = await PaginatedResult<ListUsersResponse>.CreateAsync(
_db.Users.Select(u => new ListUsersResponse
{
Id = u.Id, Name = u.Name, Email = u.Email
}),
req.PageNumber, req.PageSize, ct);
return Success(_localizer["DataRetrievedSuccessfully"], result);
}
}
Note: The source generator automatically discovers your endpoints, registers them with FastEndpoints, and generates endpoint summaries. You don't need to manually wire anything.
Result Pattern
All endpoints return Result<T>:
public sealed class Result<T>
{
public int Code { get; init; } // 0 = success, non-zero = error
public string Message { get; init; } // Human-readable message
public T? Data { get; init; } // Response payload
public IReadOnlyList<string>? Errors { get; init; } // Validation/error details
}
Success response example:
{
"code": 0,
"message": "Users retrieved successfully.",
"data": [ ... ],
"errors": null
}
Error response example:
{
"code": 1,
"message": "Validation failed.",
"data": null,
"errors": ["PageNumber must be greater than 0"]
}
Pagination
Substratum includes a built-in PaginatedResult<T> class that works with EF Core's IQueryable<T>:
public sealed class PaginatedResult<T>
{
public int PageNumber { get; }
public int TotalPages { get; }
public int TotalCount { get; }
public IReadOnlyCollection<T> Items { get; }
public bool HasPreviousPage { get; } // true if PageNumber > 1
public bool HasNextPage { get; } // true if PageNumber < TotalPages
// Create from IQueryable (executes COUNT + SKIP/TAKE):
public static Task<PaginatedResult<T>> CreateAsync(
IQueryable<T> source, int pageNumber, int pageSize, CancellationToken ct = default);
// Create with projection (map entity to DTO):
public static Task<PaginatedResult<TResult>> CreateAsync<TSource, TResult>(
IQueryable<TSource> source, Expression<Func<TSource, TResult>> selector,
int pageNumber, int pageSize, CancellationToken ct = default);
}
Usage:
// Simple pagination:
var result = await PaginatedResult<User>.CreateAsync(
_db.Users.OrderBy(u => u.Name),
pageNumber: 1, pageSize: 10, ct);
// With projection (map entity to DTO in the query):
var result = await PaginatedResult<UserDto>.CreateAsync(
_db.Users.OrderBy(u => u.Name),
u => new UserDto { Id = u.Id, Name = u.Name },
pageNumber: 1, pageSize: 10, ct);
Base Entity
All your database entities should extend BaseEntity<T>:
public abstract class BaseEntity<T> : BaseEntity where T : struct
{
public T Id { get; init; }
}
public abstract class BaseEntity
{
public bool IsDeleted { get; set; } // Soft delete flag
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? DeletedAt { get; set; } // Automatically set when IsDeleted = true
}
When you set IsDeleted = true, the DeletedAt timestamp is automatically set to DateTimeOffset.UtcNow. When you set IsDeleted = false, DeletedAt is automatically cleared to null.
Example entity:
public sealed class User : BaseEntity<Guid>
{
public string? Name { get; set; }
public string? Email { get; set; }
public string? PhoneNumber { get; set; }
public Guid RoleId { get; set; }
public Role Role { get; private set; } = null!;
}
public sealed class Role : BaseEntity<Guid>
{
public string Name { get; set; } = string.Empty;
public ICollection<User> Users { get; private set; } = new List<User>();
}
Unit Type
When your endpoint doesn't return any data, use Unit as the response type:
public sealed class Unit { }
Usage:
public class DeleteUserEndpoint : BaseEndpoint<DeleteUserRequest, Unit>
{
public override async Task<Result<Unit>> ExecuteAsync(DeleteUserRequest req, CancellationToken ct)
{
// ... delete logic
return Success("User deleted", new Unit());
}
}
Authentication
Substratum supports four authentication schemes that can be enabled independently. When multiple schemes are enabled, they're combined into a single Substratum policy scheme that tries each scheme in order.
JWT Bearer
Enable JWT authentication in appsettings.json:
{
"Authentication": {
"JwtBearer": {
"Enabled": true,
"Options": {
"SecretKey": "YOUR_SECRET_KEY_MUST_BE_AT_LEAST_32_CHARACTERS_LONG",
"Issuer": "http://localhost:5000",
"Audience": "MyApp",
"Expiration": "1.00:00:00",
"RefreshExpiration": "7.00:00:00",
"ClockSkew": "00:02:00",
"RequireHttpsMetadata": true
}
}
}
}
JWT Bearer Options
| Property | Type | Default | Description |
|---|---|---|---|
SecretKey |
string |
"" |
HMAC-SHA256 signing key. Must be at least 32 characters. |
Issuer |
string |
"" |
Token issuer (iss claim). |
Audience |
string |
"" |
Token audience (aud claim). |
Expiration |
TimeSpan |
— | Access token lifetime. Format: days.hours:minutes:seconds |
RefreshExpiration |
TimeSpan |
7.00:00:00 |
Refresh token lifetime (7 days default). |
ClockSkew |
TimeSpan |
00:02:00 |
Allowed clock difference (2 minutes default). |
RequireHttpsMetadata |
bool |
true |
Require HTTPS for metadata endpoint. |
Using IJwtBearer
Inject IJwtBearer to create and manage tokens:
public interface IJwtBearer
{
// Create an access token (no refresh):
(string AccessToken, Guid SessionId, DateTimeOffset Expiration) CreateToken(Guid userId);
(string AccessToken, Guid SessionId, DateTimeOffset Expiration) CreateToken(Guid userId, string appId);
// Create access + refresh token pair (requires IRefreshTokenStore):
Task<(string AccessToken, string RefreshToken, Guid SessionId,
DateTimeOffset AccessExpiration, DateTimeOffset RefreshExpiration)>
CreateTokenPairAsync(Guid userId, CancellationToken ct = default);
Task<(string AccessToken, string RefreshToken, Guid SessionId,
DateTimeOffset AccessExpiration, DateTimeOffset RefreshExpiration)>
CreateTokenPairAsync(Guid userId, string appId, CancellationToken ct = default);
// Refresh an expired access token:
Task<(string AccessToken, string RefreshToken,
DateTimeOffset AccessExpiration, DateTimeOffset RefreshExpiration)?>
RefreshAsync(string refreshToken, CancellationToken ct = default);
Task<(string AccessToken, string RefreshToken,
DateTimeOffset AccessExpiration, DateTimeOffset RefreshExpiration)?>
RefreshAsync(string refreshToken, string appId, CancellationToken ct = default);
}
Example — simple login:
var (token, sessionId, expiration) = jwtBearer.CreateToken(user.Id);
return Success("Login successful", new LoginResponse
{
AccessToken = token,
SessionId = sessionId,
Expiration = expiration
});
Refresh Tokens
To use refresh tokens, implement IRefreshTokenStore:
public interface IRefreshTokenStore
{
Task StoreAsync(Guid userId, Guid sessionId, string tokenHash,
DateTimeOffset expiration, CancellationToken ct);
Task<RefreshTokenValidationResult?> ValidateAndRevokeAsync(string tokenHash, CancellationToken ct);
Task RevokeBySessionAsync(Guid sessionId, CancellationToken ct);
Task RevokeAllAsync(Guid userId, CancellationToken ct);
}
public sealed class RefreshTokenValidationResult
{
public Guid UserId { get; init; }
public Guid SessionId { get; init; }
}
Once implemented, the source generator auto-registers it. Then use token pairs:
// Create access + refresh:
var result = await jwtBearer.CreateTokenPairAsync(user.Id, ct);
// result.AccessToken, result.RefreshToken, result.SessionId, etc.
// Refresh later:
var refreshed = await jwtBearer.RefreshAsync(refreshToken, ct);
if (refreshed is null) return Failure(401, "Invalid refresh token");
Token rotation is built-in: each refresh invalidates the old token and issues a new pair (rotate-on-use).
Cookie
Enable cookie authentication:
{
"Authentication": {
"Cookie": {
"Enabled": true,
"Options": {
"Scheme": "Cookies",
"CookieName": ".Substratum.Auth",
"Expiration": "365.00:00:00",
"SlidingExpiration": true,
"Secure": true,
"HttpOnly": true,
"SameSite": "Lax",
"AppIdHeaderName": "X-APP-ID"
}
}
}
}
Cookie Options
| Property | Type | Default | Description |
|---|---|---|---|
Scheme |
string |
"" |
Authentication scheme name. |
CookieName |
string |
"" |
Cookie name sent to browser. |
Expiration |
TimeSpan |
— | Cookie lifetime. |
SlidingExpiration |
bool |
false |
Renew cookie on each request. |
Secure |
bool |
false |
Require HTTPS for cookie. |
HttpOnly |
bool |
false |
Prevent JavaScript access. |
SameSite |
SameSiteMode |
Lax |
Cookie SameSite policy (Strict, Lax, None). |
AppIdHeaderName |
string |
"X-APP-ID" |
Header name for app ID extraction. |
Using ICookieAuth
public interface ICookieAuth
{
Task<(Guid SessionId, DateTimeOffset Expiration)> SignInAsync(
HttpContext httpContext, Guid userId, CancellationToken ct = default);
Task<(Guid SessionId, DateTimeOffset Expiration)> SignInAsync(
HttpContext httpContext, Guid userId, string appId, CancellationToken ct = default);
Task SignOutAsync(HttpContext httpContext, CancellationToken ct = default);
Task SignOutAsync(HttpContext httpContext, string appId, CancellationToken ct = default);
}
Example:
var (sessionId, expiration) = await cookieAuth.SignInAsync(HttpContext, user.Id, ct);
Basic Authentication
Enable HTTP Basic authentication:
{
"Authentication": {
"BasicAuthentication": {
"Enabled": true,
"Options": {
"Realm": "MyApp"
}
}
}
}
You must implement IBasicAuthValidator:
public interface IBasicAuthValidator
{
Task<(bool Result, string UserId, string SessionId)> ValidateAsync(
HttpContext context, string username, string password, CancellationToken cancellationToken);
}
Access Key
Enable API key authentication:
{
"Authentication": {
"AccessKeyAuthentication": {
"Enabled": true,
"Options": {
"Realm": "MyApp",
"KeyName": "X-API-KEY"
}
}
}
}
You must implement IAccessKeyValidator:
public interface IAccessKeyValidator
{
Task<(bool Result, string UserId, string SessionId)> ValidateAsync(
HttpContext context, string accessKey, CancellationToken cancellationToken);
}
Combined Authentication
When multiple schemes are enabled, Substratum creates a combined "Substratum" policy scheme. The request is authenticated against whichever scheme matches. You don't need to configure this — it's automatic.
The scheme names are available as constants:
public static class SubstratumAuthSchemes
{
public const string Combined = "Substratum"; // Policy scheme (default)
public const string Bearer = "Bearer"; // JWT Bearer
public const string Cookies = "Cookies"; // Cookie auth
public const string Basic = "Basic"; // Basic auth
public const string AccessKey = "ApiKey"; // API key auth
}
App-Scoped Authentication
Substratum supports multi-app authentication. This is useful when a single backend serves multiple frontend applications (web, mobile, admin panel, etc.).
Pass appId when creating tokens:
var (token, sessionId, expiration) = jwtBearer.CreateToken(userId, appId: "mobile-app");
The appId is stored as a claim and available via ICurrentUser.AppId.
For cookie auth, Substratum reads the AppIdHeaderName header (default: X-APP-ID) from the request.
You can implement IAppResolver to validate app IDs:
public interface IAppResolver
{
Task<bool> ValidateAsync(string appId, CancellationToken ct = default);
}
Two-Factor Authentication (TOTP)
Substratum includes a TOTP (Time-Based One-Time Password) provider for 2FA:
public interface ITotpProvider
{
string GenerateSecret();
string GenerateQrCodeUri(string secret, string accountName, string issuer);
bool ValidateCode(string secret, string code);
}
Example — setup 2FA:
// Generate secret for user:
var secret = totpProvider.GenerateSecret();
// Save secret to database...
// Generate QR code URI for authenticator app:
var qrUri = totpProvider.GenerateQrCodeUri(secret, "user@example.com", "MyApp");
// Return qrUri to the frontend to display as QR code
// Validate code from authenticator app:
bool isValid = totpProvider.ValidateCode(secret, "123456");
The TOTP provider uses HMAC-SHA1, 6-digit codes, 30-second time steps, and RFC-specified network delay window.
Current User
Inject ICurrentUser to access the authenticated user:
public interface ICurrentUser
{
Guid? UserId { get; } // Parsed from "uid" claim
string? AppId { get; } // Parsed from "aid" claim
PermissionDefinition[] Permissions { get; } // Resolved from "permissions" claims
}
| Property | Source | Description |
|---|---|---|
UserId |
uid claim |
The authenticated user's ID (GUID). null if not authenticated. |
AppId |
aid claim |
The application ID when multi-app auth is used. null if not set. |
Permissions |
permissions claims |
All PermissionDefinition objects for the current user. Empty array if not authenticated or no permissions hydrated. |
The Permissions property reads all claims with type "permissions" from the current ClaimsPrincipal and resolves each permission code back to its PermissionDefinition using the source-generated TryParse method from your IPermissionRegistry implementation. This means:
- Permissions are only available after the
IPermissionHydratorhas run (which happens automatically via the claims transformer). - Unknown permission codes are silently skipped.
- The result is always a fresh array (not cached), so it reflects the current claims state.
Example:
public class MyEndpoint : BaseEndpoint<MyRequest, MyResponse>
{
private readonly ICurrentUser _currentUser;
public MyEndpoint(ICurrentUser currentUser) => _currentUser = currentUser;
public override async Task<Result<MyResponse>> ExecuteAsync(MyRequest req, CancellationToken ct)
{
var userId = _currentUser.UserId;
if (userId is null)
return Failure(401, "Not authenticated");
// Access the user's permissions
var permissions = _currentUser.Permissions;
var canViewAll = permissions.Any(p => p.Code == AppPermissions.DocumentsViewAll.Code);
// ...
}
}
Password Hasher
public interface IPasswordHasher
{
string HashPassword(string password);
bool VerifyHashedPassword(string hashedPassword, string providedPassword, out bool needsRehash);
}
The password hasher uses PBKDF2 with HMAC-SHA256, 600,000 iterations, 16-byte salt, and 32-byte subkey. It's a singleton and also available as PasswordHasher.Instance for static access.
Example:
// Hash a new password:
var hash = passwordHasher.HashPassword("mysecretpassword");
// Verify a password:
bool isValid = passwordHasher.VerifyHashedPassword(hash, "mysecretpassword", out bool needsRehash);
// If needsRehash is true, re-hash with current parameters:
if (isValid && needsRehash)
{
var newHash = passwordHasher.HashPassword("mysecretpassword");
// Save newHash to database
}
Claims Types
Substratum uses short claim names for compact tokens:
public static class SubstratumClaimsTypes
{
public const string UserId = "uid";
public const string SessionId = "sid";
public const string AppId = "aid";
}
Supporting Interfaces
ISessionValidator
Validate that a session is still active (e.g., not revoked):
public interface ISessionValidator
{
Task<bool> ValidateAsync(HttpContext context, string userId, string sessionId);
}
When implemented, this is called on every authenticated request to verify the session hasn't been revoked.
IPermissionHydrator
Load user permissions into the claims principal:
public interface IPermissionHydrator
{
Task HydrateAsync(IServiceProvider serviceProvider, ClaimsPrincipal principal,
CancellationToken cancellationToken);
}
This is called after authentication to add permission claims that PermissionsAny/PermissionsAll check against.
Permissions
Substratum has a compile-time permission system. Define your permissions as static fields:
public static partial class AppPermissions
{
public static readonly PermissionDefinition Users_List = new(
code: "users.list",
name: "Users_List",
displayName: "List Users",
groupCode: "users",
groupName: "Users",
groupDisplayName: "User Management",
description: "View the list of all users"
);
public static readonly PermissionDefinition Users_Create = new(
code: "users.create",
name: "Users_Create",
displayName: "Create User",
groupCode: "users",
groupName: "Users",
groupDisplayName: "User Management"
);
}
PermissionDefinition Properties
| Property | Type | Description |
|---|---|---|
Code |
string |
Unique permission code (e.g., "users.list"). Stored in claims. |
Name |
string |
C# member name (e.g., "Users_List"). |
DisplayName |
string |
Human-friendly name for UI display. |
GroupCode |
string |
Group identifier for organizing permissions. |
GroupName |
string |
Group name. |
GroupDisplayName |
string |
Human-friendly group name for UI display. |
GroupNameSegmentLength |
int |
How many segments of the name to use for grouping. |
Description |
string? |
Optional description. |
The Permissions source generator automatically:
- Discovers all
PermissionDefinitionstatic fields - Generates an
IPermissionRegistryimplementation - Generates
Parse(string code)andTryParse(string code, out PermissionDefinition? result)methods - Generates a
Definitions()method that returns all permissions
Use permissions in endpoints:
public override void Configure()
{
Get("/api/users");
PermissionsAll(AppPermissions.Users_List); // Require ALL listed permissions
PermissionsAny(AppPermissions.Users_List, AppPermissions.Users_Create); // Require ANY of the listed permissions
}
Document Groups
Document groups let you organize your API into separate Swagger/Scalar documents. Define them as static fields:
public static partial class DocumentGroups
{
public static readonly DocGroupDefinition PublicApi = new(
name: "Public API",
url: "public",
isDefault: true,
description: "Public-facing endpoints"
);
public static readonly DocGroupDefinition AdminApi = new(
name: "Admin API",
url: "admin",
permission: AppPermissions.Admin_Access,
description: "Administrative endpoints"
);
}
The DocGroup source generator discovers these and generates an IDocGroupRegistry implementation. Assign endpoints to groups:
public override void Configure()
{
Get("/api/admin/users");
DocGroup(DocumentGroups.AdminApi);
}
If a DocGroupDefinition has a Permission, Substratum protects that document group endpoint. You need either IAccessKeyValidator or IBasicAuthValidator to access protected doc groups.
Entity Framework
Single DbContext
Define your DbContext:
public class AppDbContext : DbContext
{
public DbSet<User> Users => Set<User>();
public DbSet<Role> Roles => Set<Role>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
Configure in appsettings.json:
{
"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"
}
}
}
}
}
The source generator auto-discovers your DbContext and wires it with the configuration. It uses the [DbContextName("Default")] attribute to match DbContexts to configuration sections:
[DbContextName("Default")]
public class AppDbContext : DbContext { ... }
If you only have one DbContext, the attribute is optional — it defaults to "Default".
Multiple DbContexts
You can configure multiple database connections:
{
"EntityFramework": {
"Default": {
"Provider": "Npgsql",
"ConnectionString": "Host=localhost;Database=main_db;..."
},
"Analytics": {
"Provider": "SqlServer",
"ConnectionString": "Server=localhost;Database=analytics_db;..."
}
}
}
Use [DbContextName] to match each DbContext to its configuration:
[DbContextName("Default")]
public class AppDbContext : DbContext { ... }
[DbContextName("Analytics")]
public class AnalyticsDbContext : DbContext { ... }
Database Providers
public enum EntityFrameworkProviders
{
SqlServer, // Microsoft SQL Server
Npgsql, // PostgreSQL
Sqlite // SQLite
}
All providers automatically get:
- snake_case naming convention (
EFCore.NamingConventions) - Check constraints (
EFCore.CheckConstraints) - Retry policy (when enabled)
- Second-level cache (when enabled)
Database Seeding
When EnableSeeding is true, Substratum looks for an IDbContextInitializer<TDbContext> implementation:
public interface IDbContextInitializer<in TDbContext> where TDbContext : DbContext
{
Task SeedAsync(TDbContext dbContext, CancellationToken ct = default);
}
Example:
public class AppDbContextInitializer : IDbContextInitializer<AppDbContext>
{
public async Task SeedAsync(AppDbContext dbContext, CancellationToken ct = default)
{
if (!await dbContext.Roles.AnyAsync(ct))
{
dbContext.Roles.Add(new Role { Id = Guid.NewGuid(), Name = "Admin" });
await dbContext.SaveChangesAsync(ct);
}
}
}
There's also a non-generic IDbContextInitializer that receives a base DbContext:
public interface IDbContextInitializer
{
Task SeedAsync(DbContext dbContext, CancellationToken ct = default);
}
Retry Policy
Automatically retries failed database operations (transient failures):
{
"RetryPolicy": {
"Enabled": true,
"Options": {
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
}
}
}
| Property | Type | Default | Description |
|---|---|---|---|
MaxRetryCount |
int |
3 |
Maximum retry attempts. |
MaxRetryDelaySeconds |
int |
5 |
Maximum delay between retries in seconds. |
Second-Level Cache
Caches EF Core query results to avoid repeated database roundtrips:
{
"SecondLevelCache": {
"Enabled": true,
"Options": {
"KeyPrefix": "EF_",
"Provider": "Memory"
}
}
}
| Property | Type | Default | Description |
|---|---|---|---|
KeyPrefix |
string |
"" |
Prefix for cache keys (useful to avoid collisions). |
Provider |
SecondLevelCacheProviders |
Memory |
Memory or Redis. |
Redis.ConnectionString |
string |
"" |
Redis connection string (only when Provider = Redis). |
Redis.TimeoutSeconds |
int |
3 |
Redis operation timeout. |
Entity Framework Configuration Reference
| Property | Type | Default | Description |
|---|---|---|---|
Provider |
EntityFrameworkProviders |
SqlServer |
Database provider. |
ConnectionString |
string |
"" |
Database connection string. |
CommandTimeoutSeconds |
int |
30 |
Command timeout in seconds. |
EnableSeeding |
bool |
false |
Enable database seeding on startup. |
DbContextOptionsBuilder |
Action |
null |
Custom DbContext options (C# only). |
Logging.EnableDetailedErrors |
bool |
false |
Show detailed EF Core errors. |
Logging.EnableSensitiveDataLogging |
bool |
false |
Log parameter values (dev only!). |
OpenAPI Documentation
Substratum auto-configures OpenAPI with Scalar UI:
{
"OpenApi": {
"Enabled": true,
"Options": {
"Servers": [
{
"Url": "https://localhost:5000",
"Description": "Local Development"
}
]
}
}
}
When enabled, your API documentation is available at /scalar/{version}. The EndpointSummary source generator auto-generates summary classes and wires localized descriptions.
Localization
Substratum supports multi-language applications using .resx resource files.
1. Create resource files:
Resources/SharedResource.en.resx(English)Resources/SharedResource.ar.resx(Arabic)
2. Create a marker class:
public class SharedResource { }
3. The Localization source generator automatically discovers your .resx files and generates a [ModuleInitializer] that registers:
- The resource source type
- The default culture
- All supported cultures (detected from
.resxfile names)
4. Use in endpoints:
public class MyEndpoint : BaseEndpoint<MyRequest, MyResponse>
{
private readonly IStringLocalizer<SharedResource> _localizer;
public MyEndpoint(IStringLocalizer<SharedResource> localizer)
{
_localizer = localizer;
}
public override async Task<Result<MyResponse>> ExecuteAsync(MyRequest req, CancellationToken ct)
{
return Success(_localizer["DataRetrievedSuccessfully"], data);
}
}
5. Configure default culture in appsettings.json:
{
"Localization": {
"DefaultCulture": "en"
}
}
Spreadsheet
Substratum includes a powerful, source-generator-powered spreadsheet engine built on EPPlus. Decorate your classes with attributes, and Substratum generates all the metadata at compile time — zero reflection at runtime.
Spreadsheet Quick Start
1. Enable in appsettings.json:
{
"Spreadsheet": {
"Enabled": true,
"Options": {
"LicenseType": "NonCommercial"
}
}
}
2. Decorate your class with attributes:
using Substratum.Spreadsheet;
[Sheet(Name = "Employee Report", AutoFilter = true, FreezeHeader = true)]
public class EmployeeRow
{
[Column(Name = "Employee ID", Order = 1, Width = 15)]
[HeaderStyle(Bold = true, BackgroundColor = "#2B5797", FontColor = "#FFFFFF")]
public int Id { get; set; }
[Column(Name = "Full Name", Order = 2, Width = 25)]
public string Name { get; set; } = "";
[Column(Name = "Department", Order = 3)]
[Dropdown(Values = new[] { "Engineering", "Sales", "HR", "Finance" })]
public string Department { get; set; } = "";
[Column(Name = "Salary", Order = 4, Format = "#,##0.00")]
[ConditionalFormat(When = "> 100000", BackgroundColor = "#C6EFCE", FontColor = "#006100")]
[ConditionalFormat(When = "< 50000", BackgroundColor = "#FFC7CE", FontColor = "#9C0006")]
public decimal Salary { get; set; }
[Column(Name = "Active", Order = 5)]
[BooleanFormat(TrueValue = "Yes", FalseValue = "No")]
public bool IsActive { get; set; }
[Column(Name = "Hire Date", Order = 6)]
public DateTime HireDate { get; set; }
}
3. Export to Excel:
public class ExportEndpoint : BaseEndpoint<EmptyRequest, Unit>
{
private readonly ISpreadsheet _spreadsheet;
public ExportEndpoint(ISpreadsheet spreadsheet) => _spreadsheet = spreadsheet;
public override async Task<Result<Unit>> ExecuteAsync(EmptyRequest req, CancellationToken ct)
{
var employees = GetEmployees();
var file = _spreadsheet.ToFile(employees, "employees.xlsx");
await SendBytesAsync(file.GetBytes(), file.FileName, file.ContentType, cancellation: ct);
return Success("Exported", new Unit());
}
}
Attributes Reference
Sheet Attribute
Applied to a class to configure the sheet:
[Sheet(
Name = "Report", // Sheet tab name (default: class name sans "Row"/"Sheet"/"Export" suffix)
RightToLeft = false, // RTL layout for Arabic/Hebrew
AutoFilter = true, // Enable auto-filter dropdowns on all columns
FreezeHeader = true, // Freeze the first row
TabColor = "#FF0000", // Sheet tab color (HTML color)
Protection = SheetProtectionMode.None, // None, Password, or Sealed
Password = null, // Protection password (when Protection = Password)
AllowSort = true, // Allow sorting in protected sheets
AllowFilter = true, // Allow filtering in protected sheets
AllowSelectLockedCells = true,
AllowSelectUnlockedCells = true
)]
public class MyRow { ... }
Column Attribute
Applied to properties to configure columns:
[Column(
Name = "Column Header", // Header text (default: property name)
Order = 1, // Column position (1-based). Lower numbers appear first.
Width = 20, // Column width in characters (0 = auto-fit)
Format = "#,##0.00", // Excel number format string
Hidden = false, // Hide the column
WrapText = false // Enable text wrapping in cells
)]
public string MyProperty { get; set; }
HeaderStyle Attribute
Applied to properties to style the header cell:
[HeaderStyle(
Bold = true,
Italic = false,
Underline = false,
BackgroundColor = "#2B5797", // HTML color
FontColor = "#FFFFFF", // HTML color
FontSize = 14,
FontName = "Calibri",
Alignment = HorizontalAlignment.Center // Left, Center, Right
)]
public string MyProperty { get; set; }
CellStyle Attribute
Applied to properties to style all data cells in that column:
[CellStyle(
Bold = false,
Italic = true,
BackgroundColor = "#F2F2F2",
FontColor = "#333333",
FontSize = 11,
FontName = "Calibri",
Alignment = HorizontalAlignment.Right
)]
public decimal Amount { get; set; }
ConditionalFormat Attribute
Applied to properties to highlight cells based on conditions. AllowMultiple — you can apply several to one property:
// Numeric comparisons:
[ConditionalFormat(When = "> 100", BackgroundColor = "#C6EFCE", FontColor = "#006100")]
[ConditionalFormat(When = "< 50", BackgroundColor = "#FFC7CE", FontColor = "#9C0006")]
[ConditionalFormat(When = "= 0", BackgroundColor = "#FFEB9C", Bold = true)]
[ConditionalFormat(When = ">= 75", BackgroundColor = "#DDEBF7")]
[ConditionalFormat(When = "<= 25", FontColor = "#FF0000")]
[ConditionalFormat(When = "<> 100", Italic = true)]
[ConditionalFormat(When = "between 40 60", BackgroundColor = "#E2EFDA")]
// Text operations:
[ConditionalFormat(When = "contains error", BackgroundColor = "#FFC7CE")]
[ConditionalFormat(When = "not contains ok", FontColor = "#FF0000")]
Supported operators: >, <, >=, <=, =, <>, between X Y, contains text, not contains text.
Dropdown Attribute
Adds data validation dropdown to cells:
// Static values:
[Dropdown(Values = new[] { "Active", "Inactive", "Pending" })]
public string Status { get; set; }
// From enum:
[Dropdown(EnumType = typeof(DepartmentEnum))]
public string Department { get; set; }
BooleanFormat Attribute
Displays boolean values as custom text:
[BooleanFormat(TrueValue = "Yes", FalseValue = "No")]
public bool IsActive { get; set; }
[BooleanFormat(TrueValue = "Enabled", FalseValue = "Disabled")]
public bool IsEnabled { get; set; }
Formula Attribute
Adds an Excel formula to cells (the {row} placeholder is replaced with the current row number):
[Formula(Expression = "=D{row}*E{row}", Format = "#,##0.00")]
public string Total { get; set; } // Must be string type
SummaryRow Attribute
Adds a summary row at the bottom of the data. Applied to the class, not a property. AllowMultiple:
[Sheet(Name = "Sales")]
[SummaryRow(Label = "Total:", LabelColumn = "ProductName", Function = SummaryFunction.Sum, Columns = new[] { "Amount", "Quantity" })]
public class SalesRow
{
public string ProductName { get; set; }
public decimal Amount { get; set; }
public int Quantity { get; set; }
}
| Property | Description |
|---|---|
Label |
Text to display in the summary row. |
LabelColumn |
Property name where the label appears. |
Function |
Sum, Average, Count, Min, or Max. |
Columns |
Property names to apply the function to. |
MergeWith Attribute
Merges this column's cells with another column when values match:
[MergeWith(PropertyName = "Category")]
public string SubCategory { get; set; }
IgnoreColumn Attribute
Excludes a property from the spreadsheet:
[IgnoreColumn]
public string InternalNotes { get; set; }
SearchableIndex Attribute
Creates a named range in Excel for the column data (useful for VLOOKUP):
[SearchableIndex]
public string EmployeeId { get; set; }
Spreadsheet Enums
public enum SummaryFunction { Sum, Average, Count, Min, Max }
public enum HorizontalAlignment { Left, Center, Right }
public enum SheetProtectionMode { None, Password, Sealed }
Excel Export
// Export to byte array:
byte[] bytes = spreadsheet.Export(employees);
// Export to stream:
Stream stream = spreadsheet.ExportAsStream(employees);
// Export to SpreadsheetFile (includes filename, content type, length):
SpreadsheetFile file = spreadsheet.ToFile(employees, "report.xlsx");
CSV Export
// Export to byte array:
byte[] csvBytes = spreadsheet.ExportCsv(employees);
// Export to stream:
Stream csvStream = spreadsheet.ExportCsvAsStream(employees);
// Export as string:
string csvString = spreadsheet.ExportCsvAsString(employees);
// Export to SpreadsheetFile:
SpreadsheetFile csvFile = spreadsheet.ToCsvFile(employees, "report.csv");
CSV export respects column order, skips hidden/ignored columns, applies BooleanFormat and DateTime format, and is RFC 4180 compliant.
Multi-Sheet Workbook
IWorkbookBuilder builder = spreadsheet.CreateWorkbook();
// Add typed sheets:
builder.AddSheet(employees); // Sheet name from [Sheet] attribute
builder.AddSheet(employees, "Custom Sheet Name"); // Override sheet name
builder.AddSheet(employees, new SheetOverrides { ... }); // With overrides
// Add DataTable sheet:
builder.AddSheet("Raw Data", myDataTable);
builder.AddSheet("Raw Data", myDataTable, new DataTableSheetOptions { ... });
// Set workbook metadata:
builder.WithMetadata(m =>
{
m.Author = "My App";
m.Title = "Monthly Report";
m.Subject = "Sales Data";
m.Company = "Acme Corp";
m.Comments = "Auto-generated";
m.Protection = SheetProtectionMode.Password;
m.Password = "secret";
m.LockStructure = true;
m.LockWindows = false;
});
// Build:
byte[] bytes = builder.Build();
Stream stream = builder.BuildAsStream();
SpreadsheetFile file = builder.BuildAsFile("report.xlsx");
Template Engine
Fill existing .xlsx templates with data:
// From file path:
ITemplateBuilder template = spreadsheet.FromTemplate("templates/invoice.xlsx");
// From stream or byte array:
ITemplateBuilder template = spreadsheet.FromTemplate(stream);
ITemplateBuilder template = spreadsheet.FromTemplate(bytes);
// Bind data to placeholders (replaces {{PropertyName}} in cells):
template.Bind(new
{
CompanyName = "Acme Corp",
InvoiceDate = DateTime.Now,
TotalAmount = 1234.56m
});
// Bind typed data to a named range:
template.BindTable("ItemsRange", invoiceItems);
// Apply protection:
template.WithProtection(SheetProtectionMode.Password, "secret");
template.WithWorkbookProtection(SheetProtectionMode.Sealed);
// Build:
byte[] bytes = template.Build();
SpreadsheetFile file = template.BuildAsFile("invoice.xlsx");
Import
// Import from byte array:
List<EmployeeRow> employees = spreadsheet.Import<EmployeeRow>(bytes);
// Import with validation (catches row-level errors):
ImportResult<EmployeeRow> result = spreadsheet.ImportWithValidation<EmployeeRow>(bytes);
if (result.HasErrors)
{
foreach (var error in result.Errors)
{
Console.WriteLine($"Row {error.Row}: {error.Message}");
}
}
var validData = result.Data; // Successfully imported rows
ImportResult and ImportError
public sealed class ImportResult<T> where T : class
{
public List<T> Data { get; } // Successfully imported rows
public List<ImportError> Errors { get; } // Rows that failed
public bool HasErrors { get; } // true if any errors
public bool IsValid { get; } // true if no errors
}
public sealed class ImportError
{
public int Row { get; init; } // 1-based row number
public int Column { get; init; } // 1-based column number
public string? PropertyName { get; init; }
public string? Message { get; init; }
public string? RawValue { get; init; }
}
Sheet Overrides
Override attribute settings at runtime without changing the class:
var overrides = new SheetOverrides
{
SheetName = "Custom Name", // Override [Sheet] Name
RightToLeft = true, // Override RTL setting
AutoFilter = false, // Override auto-filter
FreezeHeader = false, // Override freeze header
HiddenColumns = { "Salary" }, // Hide specific columns by property name
ColumnNameOverrides = new Dictionary<string, string>
{
["Name"] = "Employee Name", // Override column headers
["Department"] = "Dept."
},
Protection = SheetProtectionMode.Password,
Password = "secret"
};
byte[] bytes = spreadsheet.Export(employees, overrides);
Protection
Three protection modes:
| Mode | Description |
|---|---|
None |
No protection (default). |
Password |
Protected with a user-supplied password. Users can unprotect with the password. |
Sealed |
Protected with a random 48-byte password that nobody knows. Effectively read-only. |
// Via attribute:
[Sheet(Protection = SheetProtectionMode.Password, Password = "mypass",
AllowSort = true, AllowFilter = true)]
public class MyRow { ... }
// Via overrides:
var overrides = new SheetOverrides
{
Protection = SheetProtectionMode.Sealed
};
// Workbook-level protection:
builder.WithMetadata(m =>
{
m.Protection = SheetProtectionMode.Password;
m.Password = "workbook-pass";
m.LockStructure = true;
m.LockWindows = false;
});
DataTable Support
Export System.Data.DataTable directly:
var dt = new DataTable();
dt.Columns.Add("Name", typeof(string));
dt.Columns.Add("Age", typeof(int));
dt.Rows.Add("John", 30);
var builder = spreadsheet.CreateWorkbook();
builder.AddSheet("People", dt, new DataTableSheetOptions
{
AutoFilter = true,
FreezeHeader = true,
RightToLeft = false,
HeaderStyle = new CellStyleInfo { Bold = true, FontSize = 14 },
ColumnFormats = new Dictionary<string, string> { ["Age"] = "0" },
ColumnWidths = new Dictionary<string, double> { ["Name"] = 30 },
HiddenColumns = new HashSet<string>(),
Protection = SheetProtectionMode.None
});
Streaming
Export directly to a destination stream (avoids buffering in memory):
// Excel streaming:
await spreadsheet.ExportToStreamAsync(employees, outputStream);
// CSV streaming:
await spreadsheet.ExportCsvToStreamAsync(employees, outputStream);
// Workbook streaming:
await builder.BuildToStreamAsync(outputStream);
SpreadsheetFile
A disposable wrapper around the exported data:
public sealed class SpreadsheetFile : IDisposable, IAsyncDisposable
{
public string FileName { get; } // e.g., "report.xlsx"
public string ContentType { get; } // e.g., "application/vnd.openxmlformats..."
public long Length { get; } // File size in bytes
public byte[] GetBytes(); // Get as byte array
public Stream GetStream(); // Get as readable stream
public Task CopyToAsync(Stream destination); // Copy to another stream
public void CopyTo(Stream destination); // Synchronous copy
}
ISpreadsheet API Reference
public interface ISpreadsheet
{
// Single-sheet Excel export:
byte[] Export<T>(IEnumerable<T> data, SheetOverrides? overrides = null) where T : class;
Stream ExportAsStream<T>(IEnumerable<T> data, SheetOverrides? overrides = null) where T : class;
Task ExportToStreamAsync<T>(IEnumerable<T> data, Stream destination, SheetOverrides? overrides = null) where T : class;
// CSV export:
byte[] ExportCsv<T>(IEnumerable<T> data) where T : class;
Stream ExportCsvAsStream<T>(IEnumerable<T> data) where T : class;
string ExportCsvAsString<T>(IEnumerable<T> data) where T : class;
Task ExportCsvToStreamAsync<T>(IEnumerable<T> data, Stream destination) where T : class;
// File results:
SpreadsheetFile ToFile<T>(IEnumerable<T> data, string? fileName = null, SheetOverrides? overrides = null) where T : class;
SpreadsheetFile ToCsvFile<T>(IEnumerable<T> data, string? fileName = null) where T : class;
// Multi-sheet workbook:
IWorkbookBuilder CreateWorkbook();
// Template engine:
ITemplateBuilder FromTemplate(string path);
ITemplateBuilder FromTemplate(Stream stream);
ITemplateBuilder FromTemplate(byte[] bytes);
// Import:
List<T> Import<T>(byte[] data) where T : class, new();
ImportResult<T> ImportWithValidation<T>(byte[] data) where T : class, new();
}
IWorkbookBuilder API Reference
public interface IWorkbookBuilder
{
IWorkbookBuilder AddSheet<T>(IEnumerable<T> data) where T : class;
IWorkbookBuilder AddSheet<T>(IEnumerable<T> data, SheetOverrides overrides) where T : class;
IWorkbookBuilder AddSheet<T>(string sheetName, IEnumerable<T> data) where T : class;
IWorkbookBuilder AddSheet(string sheetName, DataTable dataTable);
IWorkbookBuilder AddSheet(string sheetName, DataTable dataTable, DataTableSheetOptions options);
IWorkbookBuilder WithMetadata(Action<WorkbookMetadata> configure);
byte[] Build();
Stream BuildAsStream();
SpreadsheetFile BuildAsFile(string? fileName = null);
Task BuildToStreamAsync(Stream destination);
}
ITemplateBuilder API Reference
public interface ITemplateBuilder
{
ITemplateBuilder Bind(object data);
ITemplateBuilder BindTable<T>(string rangeName, IEnumerable<T> data) where T : class;
ITemplateBuilder WithProtection(SheetProtectionMode mode = SheetProtectionMode.Password, string? password = null);
ITemplateBuilder WithWorkbookProtection(SheetProtectionMode mode = SheetProtectionMode.Password, string? password = null);
byte[] Build();
Stream BuildAsStream();
SpreadsheetFile BuildAsFile(string? fileName = null);
}
Spreadsheet Source Generator Diagnostics
The spreadsheet source generator validates your attributes at compile time:
| ID | Severity | Description |
|---|---|---|
| SS001 | Error | Class has no public properties. |
| SS002 | Error | Class has no parameterless constructor. |
| SS003 | Error | ConditionalFormat.When expression couldn't be parsed. |
| SS004 | Error | [Formula] can only be applied to string properties. |
| SS005 | Error | [BooleanFormat] can only be applied to bool properties. |
| SS006 | Error | [Dropdown(EnumType)] — type is not an enum. |
| SS007 | Error | [SummaryRow] references a column that doesn't exist. |
| SS008 | Warning | Duplicate Order values on different columns. |
| SS009 | Error | [MergeWith] references a property that doesn't exist. |
| SS010 | Warning | Property has both [Column] and [IgnoreColumn]. |
| SS011 | Warning | Protection = Password but no password provided. |
| SS012 | Info | Password set but Protection = None. |
| SS013 | Info | Protection = Sealed with password (password will be ignored). |
Spreadsheet Configuration
{
"Spreadsheet": {
"Enabled": true,
"Options": {
"LicenseType": "NonCommercial",
"LicenseKey": null,
"DefaultDateFormat": "yyyy-MM-dd",
"DefaultNumberFormat": "#,##0.00",
"DefaultFont": {
"Name": "Calibri",
"Size": 11
},
"HeaderStyle": {
"Bold": true,
"FontSize": 12,
"BackgroundColor": "#2B5797",
"FontColor": "#FFFFFF",
"FreezeHeader": true
},
"Csv": {
"Delimiter": ",",
"Encoding": "UTF-8",
"IncludeHeader": true,
"DateFormat": "yyyy-MM-dd",
"NullValue": ""
},
"RightToLeft": false,
"MaxExportRows": 100000,
"TemplatePath": "templates",
"AutoFitColumns": true,
"AutoFitMaxWidth": 60,
"DefaultColumnWidth": 15
}
}
}
SpreadsheetOptions Properties
| Property | Type | Default | Description |
|---|---|---|---|
LicenseType |
string |
"NonCommercial" |
"NonCommercial" or "Commercial". |
LicenseKey |
string? |
null |
EPPlus commercial license key. |
DefaultDateFormat |
string |
"yyyy-MM-dd" |
Date format for date columns without [Column(Format)]. |
DefaultNumberFormat |
string |
"#,##0.00" |
Number format for decimal/float columns. |
DefaultFont.Name |
string |
"Calibri" |
Default font family. |
DefaultFont.Size |
float |
11 |
Default font size. |
HeaderStyle.Bold |
bool |
true |
Bold headers by default. |
HeaderStyle.FontSize |
float |
12 |
Header font size. |
HeaderStyle.BackgroundColor |
string |
"#2B5797" |
Header background color. |
HeaderStyle.FontColor |
string |
"#FFFFFF" |
Header font color. |
HeaderStyle.FreezeHeader |
bool |
true |
Freeze header row by default. |
Csv.Delimiter |
string |
"," |
CSV column delimiter. |
Csv.Encoding |
string |
"UTF-8" |
CSV file encoding. |
Csv.IncludeHeader |
bool |
true |
Include header row in CSV. |
Csv.DateFormat |
string |
"yyyy-MM-dd" |
Date format in CSV output. |
Csv.NullValue |
string |
"" |
Value to write for null cells. |
RightToLeft |
bool |
false |
Global RTL default. |
MaxExportRows |
int |
100000 |
Maximum rows per export. |
TemplatePath |
string |
"templates" |
Base path for template files. |
AutoFitColumns |
bool |
true |
Auto-fit column widths. |
AutoFitMaxWidth |
double |
60 |
Maximum auto-fit width. |
DefaultColumnWidth |
double |
15 |
Default column width when not auto-fitting. |
Image Processing
Substratum includes IImageService for image resizing, WebP compression, and BlurHash generation:
public interface IImageService
{
Task<ImageResult> ProcessAsync(Stream input, ImageProcessingOptions? options = null,
CancellationToken ct = default);
Task<string> BlurHashAsync(Stream input, int componentsX = 4, int componentsY = 3,
CancellationToken ct = default);
}
IImageService is registered as a singleton automatically — just inject it.
ImageProcessingOptions
public sealed class ImageProcessingOptions
{
public int MaxWidth { get; init; } = 512; // Max width in pixels
public int MaxHeight { get; init; } = 512; // Max height in pixels
public int Quality { get; init; } = 70; // WebP quality (0-100)
public bool SkipMetadata { get; init; } = true; // Strip EXIF data
public bool NearLossless { get; init; } = true; // Use near-lossless WebP encoding
public int NearLosslessQuality { get; init; } = 50; // Near-lossless quality
}
ImageResult
public sealed class ImageResult : IDisposable
{
public MemoryStream Stream { get; init; } // Processed image data
public string ContentType { get; init; } // Always "image/webp"
public long Length { get; } // Byte count
public int Width { get; init; } // Final pixel width
public int Height { get; init; } // Final pixel height
}
Example:
// Resize and compress to WebP:
using var result = await imageService.ProcessAsync(uploadedFile.OpenReadStream(), new ImageProcessingOptions
{
MaxWidth = 800,
MaxHeight = 600,
Quality = 80
});
await fileStorage.UploadAsync("images/photo.webp", result.Stream, result.ContentType, ct);
// Generate BlurHash placeholder:
string blurHash = await imageService.BlurHashAsync(uploadedFile.OpenReadStream());
// Returns something like: "LEHV6nWB2yk8pyo0adR*.7kCMdnj"
Cloud Storage
MinIO (S3-Compatible)
{
"Minio": {
"Enabled": true,
"Options": {
"Endpoint": "play.min.io",
"Region": "us-east-1",
"Secure": true,
"AccessKey": "YOUR_ACCESS_KEY",
"SecretKey": "YOUR_SECRET_KEY"
}
}
}
When enabled, the IMinioClient from the official Minio SDK is registered and ready to inject.
AWS S3
{
"Aws": {
"S3": {
"Enabled": true,
"Options": {
"Endpoint": null,
"Region": "us-east-1",
"ForcePathStyle": false,
"AccessKey": "YOUR_ACCESS_KEY",
"SecretKey": "YOUR_SECRET_KEY"
}
}
}
}
When enabled, IAmazonS3 from the AWS SDK is registered and ready to inject.
Azure Blob Storage
{
"Azure": {
"BlobStorage": {
"Enabled": true,
"Options": {
"ConnectionString": "YOUR_AZURE_BLOB_STORAGE_CONNECTION_STRING"
}
}
}
}
When enabled, BlobServiceClient from the Azure SDK is registered and ready to inject.
Unified File Storage (IFileStorage)
Substratum provides a unified file storage interface that works with Local filesystem, AWS S3, and Azure Blob Storage:
public enum StorageProvider { Local, S3, AzureBlob }
public interface IFileStorage
{
// Provider-specific operations:
Task UploadAsync(StorageProvider provider, string container, string path,
Stream content, string? contentType = null, CancellationToken ct = default);
Task<Stream> DownloadAsync(StorageProvider provider, string container, string path,
CancellationToken ct = default);
Task DeleteAsync(StorageProvider provider, string container, string path,
CancellationToken ct = default);
Task<bool> ExistsAsync(StorageProvider provider, string container, string path,
CancellationToken ct = default);
// Default provider operations (uses configured provider & container):
Task UploadAsync(string path, Stream content, string? contentType = null,
CancellationToken ct = default);
Task<Stream> DownloadAsync(string path, CancellationToken ct = default);
Task DeleteAsync(string path, CancellationToken ct = default);
Task<bool> ExistsAsync(string path, CancellationToken ct = default);
}
Configure in appsettings.json:
{
"FileStorage": {
"Enabled": true,
"Options": {
"Provider": "Local",
"Container": "uploads",
"MaxFileSizeBytes": 52428800,
"AllowedExtensions": [".jpg", ".png", ".pdf", ".docx"]
}
}
}
FileStorageOptions
| Property | Type | Default | Description |
|---|---|---|---|
Provider |
StorageProvider |
Local |
Default storage provider. |
Container |
string |
"" |
Default bucket/container name. |
MaxFileSizeBytes |
long |
0 |
Max upload size (0 = unlimited). |
AllowedExtensions |
string[] |
[] |
Allowed file extensions (empty = all). |
Example:
// Upload using default provider:
await fileStorage.UploadAsync("documents/report.pdf", fileStream, "application/pdf", ct);
// Upload to specific provider:
await fileStorage.UploadAsync(StorageProvider.S3, "my-bucket", "docs/report.pdf", fileStream, ct: ct);
// Download:
var stream = await fileStorage.DownloadAsync("documents/report.pdf", ct);
// Check existence:
bool exists = await fileStorage.ExistsAsync("documents/report.pdf", ct);
// Delete:
await fileStorage.DeleteAsync("documents/report.pdf", ct);
AWS Secrets Manager
Load secrets from AWS Secrets Manager into your configuration:
{
"Aws": {
"SecretsManager": {
"Enabled": true,
"Options": {
"Region": "us-east-1",
"SecretArns": ["arn:aws:secretsmanager:us-east-1:123:secret:my-secret"],
"ServiceUrl": null,
"AccessKey": "YOUR_ACCESS_KEY",
"SecretKey": "YOUR_SECRET_KEY"
}
}
}
}
When enabled, secrets are loaded during startup and merged into IConfiguration. Secret keys are flattened using : notation (e.g., Authentication:JwtBearer:Options:SecretKey).
Firebase
Cloud Messaging
{
"Firebase": {
"Messaging": {
"Enabled": true,
"Options": {
"Credential": "BASE_64_ENCODED_SERVICE_ACCOUNT_JSON"
}
}
}
}
When enabled, the Firebase Admin SDK is initialized and FirebaseMessaging is available for sending push notifications.
App Check
{
"Firebase": {
"AppCheck": {
"Enabled": true,
"Options": {
"ProjectId": "YOUR_FIREBASE_PROJECT_ID",
"ProjectNumber": "YOUR_FIREBASE_PROJECT_NUMBER",
"EnableEmulator": false,
"EmulatorTestToken": "TEST_TOKEN_FOR_DEVELOPMENT"
}
}
}
}
Inject IFirebaseAppCheck to verify App Check tokens:
public interface IFirebaseAppCheck
{
Task<bool> VerifyAppCheckTokenAsync(string token, CancellationToken ct = default);
}
When EnableEmulator = true, the service accepts the EmulatorTestToken for local development.
Infrastructure
CORS
{
"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
}
}
| Property | Type | Default | Description |
|---|---|---|---|
AllowedOrigins |
string[] |
[] |
Allowed origins. |
AllowedMethods |
string[] |
[] |
Allowed HTTP methods. |
AllowedHeaders |
string[] |
[] |
Allowed request headers. |
AllowCredentials |
bool |
true |
Allow credentials (cookies, auth headers). |
MaxAgeSeconds |
int |
600 |
Preflight cache duration (seconds). |
Response Compression
{
"ResponseCompression": {
"Enabled": true,
"Options": {
"EnableForHttps": true,
"Providers": ["Brotli", "Gzip"],
"MimeTypes": ["text/plain", "application/json", "text/html"]
}
}
}
| Property | Type | Default | Description |
|---|---|---|---|
EnableForHttps |
bool |
true |
Compress HTTPS responses. |
Providers |
string[] |
["Brotli", "Gzip"] |
Compression algorithms. |
MimeTypes |
string[] |
[] |
MIME types to compress (empty = framework defaults). |
Enabled by default —
ResponseCompressionstarts enabled without explicit config.
Forwarded Headers
For apps behind a reverse proxy (nginx, load balancer):
{
"ForwardedHeaders": {
"Enabled": true,
"Options": {
"ForwardedHeaders": ["XForwardedFor", "XForwardedProto"],
"KnownProxies": [],
"KnownNetworks": []
}
}
}
Request Limits
{
"RequestLimits": {
"MaxRequestBodySizeBytes": 52428800,
"MaxMultipartBodyLengthBytes": 134217728
}
}
| Property | Type | Default | Description |
|---|---|---|---|
MaxRequestBodySizeBytes |
long |
52428800 |
Max request body size (50 MB). |
MaxMultipartBodyLengthBytes |
long |
134217728 |
Max multipart upload size (128 MB). |
Rate Limiting
{
"RateLimiting": {
"Enabled": true,
"Options": {
"GlobalPolicy": "Default",
"RejectionStatusCode": 429,
"Policies": {
"Default": {
"Type": "FixedWindow",
"PermitLimit": 100,
"WindowSeconds": 60,
"QueueLimit": 0
},
"Strict": {
"Type": "SlidingWindow",
"PermitLimit": 10,
"WindowSeconds": 60,
"SegmentsPerWindow": 6,
"QueueLimit": 0
}
}
}
}
}
Rate Limiting Policy Types
public enum RateLimitingPolicyType
{
FixedWindow, // Fixed time window
SlidingWindow, // Sliding window with segments
TokenBucket, // Token bucket algorithm
Concurrency // Concurrent request limit
}
Policy Options
| Property | Type | Default | Applies To | Description |
|---|---|---|---|---|
Type |
RateLimitingPolicyType |
FixedWindow |
All | Algorithm type. |
PermitLimit |
int |
100 |
Fixed, Sliding, Concurrency | Max requests allowed. |
WindowSeconds |
int |
60 |
Fixed, Sliding | Time window duration. |
SegmentsPerWindow |
int |
6 |
Sliding only | Window segments. |
TokenLimit |
int |
10 |
TokenBucket only | Max tokens. |
ReplenishmentPeriodSeconds |
int |
10 |
TokenBucket only | Token replenishment interval. |
TokensPerPeriod |
int |
2 |
TokenBucket only | Tokens added per period. |
QueueLimit |
int |
0 |
All | Queued requests limit. |
Custom Partition Key
By default, rate limiting partitions by authenticated user ID or IP address. Implement IRateLimitPartitioner for custom partitioning:
public interface IRateLimitPartitioner
{
string GetPartitionKey(HttpContext httpContext, string policyName);
}
Distributed Cache
{
"DistributedCache": {
"Enabled": true,
"Options": {
"Provider": "Redis",
"Redis": {
"ConnectionString": "localhost:6379",
"InstanceName": "DC_"
}
}
}
}
| Provider | Description |
|---|---|
Memory |
In-memory distributed cache (single server). |
Redis |
Redis-backed distributed cache (multi-server). |
When enabled, IDistributedCache is available for injection.
Health Checks
{
"HealthChecks": {
"Enabled": true,
"Options": {
"Path": "/healthz"
}
}
}
Access at GET /healthz. Automatically includes DbContext health checks for all registered Entity Framework contexts.
You can add custom health checks via C# configuration:
return await SubstratumApp.RunAsync(args, options =>
{
options.HealthChecks.Options.HealthChecksBuilder = builder =>
{
builder.AddRedis("localhost:6379");
};
});
Static Files
{
"StaticFiles": {
"Enabled": true,
"Options": {
"RootPath": "wwwroot",
"RequestPath": "",
"ContentTypeMappings": {
".custom": "application/x-custom-type"
}
}
}
}
Error Handling
{
"ErrorHandling": {
"IncludeExceptionDetails": true
}
}
When IncludeExceptionDetails is true, exception stack traces and messages are included in error responses. Set to false in production!
Logging (Serilog)
Substratum uses Serilog for structured logging, configured entirely through appsettings.json:
{
"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" }
]
}
}
Sensitive data masking is built-in — add property names to MaskProperties and they'll be automatically masked in logs.
SMTP email sending via MailKit. Supports HTML/plain text, attachments, CC/BCC, reply-to, and simple template variables.
{
"Email": {
"Enabled": true,
"Options": {
"Host": "smtp.example.com",
"Port": 587,
"Username": "your-username",
"Password": "your-password",
"FromAddress": "noreply@example.com",
"FromName": "My App",
"UseSsl": true,
"TimeoutMs": 30000
}
}
}
Usage:
public class SendWelcomeEndpoint : BaseEndpoint<SendWelcomeRequest, Unit>
{
public required IEmailService EmailService { get; init; }
public override void Configure()
{
Post("/emails/welcome");
}
public override async Task HandleAsync(SendWelcomeRequest req, CancellationToken ct)
{
await EmailService.SendAsync(new EmailMessage
{
To = [req.Email],
Subject = "Welcome!",
Body = "<h1>Welcome to our app!</h1>",
IsHtml = true
}, ct);
await SendOkAsync(ct);
}
}
Templated emails with {{placeholder}} replacement:
await EmailService.SendTemplatedAsync(
templateBody: "<p>Hello {{Name}}, your order #{{OrderId}} is confirmed.</p>",
replacements: new Dictionary<string, string>
{
["Name"] = "Ahmed",
["OrderId"] = "12345"
},
message: new EmailMessage
{
To = ["ahmed@example.com"],
Subject = "Order Confirmation",
IsHtml = true
}, ct);
| Property | Type | Default | Description |
|---|---|---|---|
Host |
string |
— | SMTP server hostname. |
Port |
int |
587 |
SMTP port. |
Username |
string |
"" |
SMTP username (skip auth if empty). |
Password |
string |
"" |
SMTP password. |
FromAddress |
string |
— | Default sender email. |
FromName |
string |
— | Default sender display name. |
UseSsl |
bool |
true |
Use SSL/TLS. |
TimeoutMs |
int |
30000 |
SMTP connection timeout in ms. |
Background Jobs
Fire-and-forget job queue and recurring interval jobs using System.Threading.Channels and BackgroundService. No external dependencies.
{
"BackgroundJobs": {
"Enabled": true,
"Options": {
"MaxConcurrency": 1,
"QueueCapacity": 100
}
}
}
Fire-and-Forget Jobs
Inject IBackgroundJobService and enqueue work items:
public class ProcessOrderEndpoint : BaseEndpoint<ProcessOrderRequest, Unit>
{
public required IBackgroundJobService Jobs { get; init; }
public override async Task HandleAsync(ProcessOrderRequest req, CancellationToken ct)
{
await Jobs.EnqueueAsync(async (sp, ct) =>
{
var db = sp.GetRequiredService<AppDbContext>();
// ... long-running work
}, ct);
await SendOkAsync(ct);
}
}
Recurring Jobs
Implement IRecurringJob — the source generator auto-discovers and registers all implementations:
public class CleanupExpiredTokensJob : IRecurringJob
{
public string Name => "CleanupExpiredTokens";
public TimeSpan Interval => TimeSpan.FromHours(1);
public bool RunOnStartup => false;
public async Task ExecuteAsync(CancellationToken ct)
{
// cleanup logic
}
}
| Property | Type | Default | Description |
|---|---|---|---|
MaxConcurrency |
int |
1 |
Max parallel job executions. |
QueueCapacity |
int |
100 |
Max queued jobs before backpressure. |
Audit Logging
Automatic entity change tracking via an EF Core SaveChangesInterceptor. Captures create, update, and delete operations and stores them in the same transaction as your business data.
{
"AuditLogging": {
"Enabled": true,
"Options": {
"IncludePropertyChanges": true
}
}
}
Quick Start
- Implement
IAuditableDbContexton your DbContext:
public class AppDbContext : DbContext, IAuditableDbContext
{
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
// ... your other DbSets
}
That's it. Every SaveChangesAsync() call now automatically captures entity changes into the AuditLogs table in the same transaction — no separate service, no extra save call.
How It Works
The interceptor runs in SavingChangesAsync (before the save). It walks the ChangeTracker, creates AuditLog entities for each Added/Modified/Deleted entity, and adds them to the AuditLogs DbSet. EF Core then persists business entities and audit logs together in a single transaction.
AuditLogentities are never audited themselves (skipped automatically)UserIdis populated fromICurrentUserwhen availableChangesis a JSON-serialized list of property changes (old/new values)
AuditLog Entity
| Property | Type | Description |
|---|---|---|
Id |
Guid |
Auto-generated primary key. |
EntityType |
string |
Full CLR type name of the entity. |
EntityId |
string |
Primary key value(s), comma-separated for composites. |
Action |
AuditAction |
Create, Update, or Delete. |
Timestamp |
DateTimeOffset |
When the change occurred. |
UserId |
Guid? |
Authenticated user ID (from ICurrentUser). |
Changes |
string? |
JSON-serialized List<PropertyChange> (when enabled). |
Custom Storage (Advanced)
If you need to store audit logs externally (separate database, message queue, etc.), implement IAuditStore instead. The source generator auto-discovers it. This path is used for DbContexts that don't implement IAuditableDbContext:
public class ExternalAuditStore : IAuditStore
{
public async Task StoreAsync(IReadOnlyList<AuditEntry> entries, CancellationToken ct)
{
// send to external system
}
}
| Option | Type | Default | Description |
|---|---|---|---|
IncludePropertyChanges |
bool |
true |
Track individual property changes. |
Encryption
AES-256-GCM encryption with key rotation support. Outputs nonce(12) + ciphertext + tag(16) format.
{
"Encryption": {
"Enabled": true,
"Options": {
"Key": "BASE64_ENCODED_32_BYTE_KEY",
"SecondaryKey": null
}
}
}
Generate a key: Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
Usage:
public class EncryptEndpoint : BaseEndpoint<EncryptRequest, EncryptResponse>
{
public required IEncryptionService Encryption { get; init; }
public override async Task HandleAsync(EncryptRequest req, CancellationToken ct)
{
var encrypted = Encryption.Encrypt(req.Plaintext);
var decrypted = Encryption.Decrypt(encrypted);
await SendOkAsync(new EncryptResponse { Ciphertext = encrypted }, ct);
}
}
Key rotation: Encrypt always uses the primary key. Decrypt tries the primary key first, then falls back to SecondaryKey. To rotate: move the current key to SecondaryKey, set a new primary Key, then re-encrypt data at your convenience.
| Property | Type | Default | Description |
|---|---|---|---|
Key |
string |
— | Base64-encoded 32-byte (256-bit) primary key. |
SecondaryKey |
string? |
null |
Base64-encoded 32-byte fallback key for rotation. |
Webhooks
Event-driven HTTP POST delivery with HMAC-SHA256 signatures, retry with exponential backoff, and optional delivery logging.
{
"Webhooks": {
"Enabled": true,
"Options": {
"Secret": "YOUR_WEBHOOK_SECRET_MIN_16_CHARS",
"MaxRetries": 3,
"RetryDelaySeconds": 5,
"TimeoutSeconds": 30,
"QueueCapacity": 100
}
}
}
Implement IWebhookStore (required) — the source generator auto-discovers it:
public class WebhookStore : IWebhookStore
{
private readonly AppDbContext _db;
public WebhookStore(AppDbContext db) => _db = db;
public async Task<IReadOnlyList<WebhookSubscription>> GetSubscriptionsAsync(
string eventType, CancellationToken ct)
{
return await _db.WebhookSubscriptions
.Where(s => s.IsActive && s.EventTypes.Contains(eventType))
.ToListAsync(ct);
}
}
Optionally implement IWebhookDeliveryLog for delivery tracking:
public class WebhookDeliveryLog : IWebhookDeliveryLog
{
public async Task LogAsync(WebhookDeliveryResult result, CancellationToken ct)
{
// persist delivery result
}
}
Publish events:
public class CreateOrderEndpoint : BaseEndpoint<CreateOrderRequest, OrderResponse>
{
public required IWebhookService Webhooks { get; init; }
public override async Task HandleAsync(CreateOrderRequest req, CancellationToken ct)
{
// ... create order ...
await Webhooks.PublishAsync("order.created", new { OrderId = order.Id, Total = order.Total }, ct);
await SendOkAsync(response, ct);
}
}
Delivery details:
- HTTP POST with JSON body
X-Webhook-Eventheader with event typeX-Webhook-Signature: sha256={hmac}header for payload verification- Exponential backoff retry:
delay * 2^(attempt-1)
| Property | Type | Default | Description |
|---|---|---|---|
Secret |
string |
— | HMAC-SHA256 signing secret (min 16 chars). |
MaxRetries |
int |
3 |
Max delivery retry attempts (0-10). |
RetryDelaySeconds |
int |
5 |
Base retry delay in seconds. |
TimeoutSeconds |
int |
30 |
HTTP request timeout. |
QueueCapacity |
int |
100 |
Max queued webhook events. |
Substratum.Generator
The Substratum.Generator package contains 8 incremental source generators that eliminate runtime reflection and automate boilerplate.
How Source Generators Work
The generators target netstandard2.0 and use Scriban templating to generate C# code at compile time. All generated code is added to the compilation automatically.
Technical details:
- Each generator uses
IIncrementalGenerator(Roslyn incremental pipeline) - Templates are embedded resources in
.sbn-csformat - Shared helpers in
Core/RoslynHelpers.csandCore/TemplateRenderer.cs - All type metadata is computed at compile time
App Generator
What it generates: A [ModuleInitializer] that sets SubstratumApp.Factory — the delegate that boots the entire application pipeline.
Why: This is how SubstratumApp.RunAsync(args) works with just one line of code. The generator discovers your project, generates the bootstrap code, and sets the Factory at module initialization.
Diagnostics:
| ID | Severity | Description |
|---|---|---|
| SA001 | Error | Template file could not be loaded. |
FastEndpoints Generators
Three generators work together for FastEndpoints integration:
1. DiscoveredTypes Generator — finds all endpoint, validator, mapper, summary, and event handler types:
| ID | Severity | Description |
|---|---|---|
| FE001 | Error | Template file could not be loaded. |
2. ServiceRegistration Generator — auto-registers services marked with [RegisterService] or discovered patterns:
| ID | Severity | Description |
|---|---|---|
| SR001 | Error | Template file could not be loaded. |
3. Reflection Generator — generates a pre-computed ReflectionCache to avoid runtime reflection:
| ID | Severity | Description |
|---|---|---|
| RE001 | Error | Template file could not be loaded. |
Permissions Generator
Discovers all PermissionDefinition static fields and generates:
- An
IPermissionRegistryimplementation - Static
Parse(string code)andTryParse(string code, out PermissionDefinition?)methods - A
Definitions()method returning all permissions
| ID | Severity | Description |
|---|---|---|
| PM001 | Error | Template file could not be loaded. |
| PM002 | Error | Template parse error. |
| PM003 | Error | Code generation error. |
Localization Generator
Discovers .resx files, extracts supported cultures, and generates a [ModuleInitializer] that auto-configures:
ResourceSourcetypeDefaultCultureSupportedCultures
| ID | Severity | Description |
|---|---|---|
| LZ001 | Error | Template file could not be loaded. |
| LZ002 | Error | Template parse error. |
| LZ003 | Error | Code generation error. |
EndpointSummary Generator
Discovers SubstratumEndpointSummary subclasses and generates code that passes the IStringLocalizer to Configure():
| ID | Severity | Description |
|---|---|---|
| ES001 | Error | Template file could not be loaded. |
| ES002 | Error | Template parse error. |
| ES003 | Error | Code generation error. |
DocGroup Generator
Discovers DocGroupDefinition static fields and generates an IDocGroupRegistry implementation:
| ID | Severity | Description |
|---|---|---|
| DG001 | Error | Template file could not be loaded. |
| DG002 | Error | Template parse error. |
| DG003 | Error | Code generation error. |
Spreadsheet Generator
Discovers classes with [Sheet] and generates:
ISheetMetadata<T>implementations with all column/style/validation metadata- A
[ModuleInitializer]registry that registers all metadata intoSheetMetadataStore - Compile-time type mapping, formula parsing, conditional format parsing
See Spreadsheet Source Generator Diagnostics for the full diagnostics table.
Generator Diagnostics Reference
All generator diagnostics follow the pattern: {prefix}{number} where prefix is SA (App), FE (FastEndpoints), SR (ServiceRegistration), RE (Reflection), PM (Permissions), LZ (Localization), ES (EndpointSummary), DG (DocGroup), SS (Spreadsheet).
CLI Tools (dotnet-sub)
Install the CLI tool:
dotnet tool install --global Substratum.Tools
Create Web App
Scaffold a complete new project:
dotnet-sub new webapp MyApp
This creates a full project structure with:
Program.cs(single-line bootstrap)appsettings.json(full configuration)Security/(permissions, session validator, access key validator)Data/(DbContext, initializer, entity configurations)Domain/(entities)Features/(sample endpoint)Resources/(localization files)
Create Endpoint
Scaffold a new endpoint with all supporting files:
# Normal endpoint:
dotnet-sub new endpoint Users/ListUsers --method GET --route "/api/v1/users" --permission "Users_ListUsers"
# Paginated endpoint:
dotnet-sub new endpoint Users/ListUsers --method GET --route "/api/v1/users" --permission "Users_ListUsers" --paginated
This creates 6 files:
EndpointNameEndpoint.csEndpointNameRequest.csEndpointNameResponse.csEndpointNameRequestValidator.csEndpointNameSerializerContext.csEndpointNameSummary.cs
Create Entity
Scaffold a new entity:
dotnet-sub new entity Product
Creates an entity class extending BaseEntity<Guid> and an EF Core configuration class.
Database Migrations
# Add a migration:
dotnet-sub migrations add InitialCreate
# Add to specific context:
dotnet-sub migrations add AddProducts --context AnalyticsDbContext
Database Update
# Update to latest migration:
dotnet-sub database update
# Update specific context:
dotnet-sub database update --context AnalyticsDbContext
Generate SQL
# Generate SQL script for all migrations:
dotnet-sub database sql
# Generate from specific migration:
dotnet-sub database sql --from InitialCreate --to AddProducts
All Contracts and Interfaces
Here is a complete list of every public interface and class that Substratum provides for you to implement or use:
Interfaces You Implement
| Interface | Required? | Description |
|---|---|---|
ISessionValidator |
Optional | Validate session on every authenticated request. |
IPermissionHydrator |
Optional | Load user permissions into claims. |
IBasicAuthValidator |
Required when Basic Auth enabled | Validate Basic auth credentials. |
IAccessKeyValidator |
Required when Access Key enabled | Validate API key. |
IRefreshTokenStore |
Optional | Store/validate/revoke refresh tokens. |
IAppResolver |
Optional | Validate app IDs for multi-app auth. |
IRateLimitPartitioner |
Optional | Custom rate limit partition key. |
IRecurringJob |
Optional (multiple) | Recurring background job (auto-discovered). |
IAuditableDbContext |
Recommended when Audit Logging enabled | Add DbSet<AuditLog> to your DbContext. |
IAuditStore |
Optional (advanced) | Custom external audit storage. |
IWebhookStore |
Required when Webhooks enabled | Provide webhook subscriptions. |
IWebhookDeliveryLog |
Optional | Log webhook delivery results. |
IDbContextInitializer<T> |
Optional | Seed database on startup. |
IDbContextInitializer |
Optional | Non-generic database seeder. |
IPermissionRegistry |
Auto-generated | Permission registry (generated by source generator). |
IDocGroupRegistry |
Auto-generated | Document group registry (generated by source generator). |
Interfaces You Inject (Services)
| Interface | Registered | Description |
|---|---|---|
IJwtBearer |
When JWT enabled | Create/refresh JWT tokens. |
ICookieAuth |
When Cookie enabled | Sign in/out with cookies. |
ICurrentUser |
Always (scoped) | Access authenticated user info (UserId, AppId, Permissions). |
IPasswordHasher |
Always (singleton) | Hash and verify passwords. |
ITotpProvider |
Always (singleton) | TOTP 2FA operations. |
IImageService |
Always (singleton) | Image processing + BlurHash. |
ISpreadsheet |
When Spreadsheet enabled | Excel/CSV export/import. |
IFileStorage |
When FileStorage enabled | Unified file storage. |
IEmailService |
When Email enabled | Send SMTP emails. |
IBackgroundJobService |
When BackgroundJobs enabled | Enqueue fire-and-forget jobs. |
IEncryptionService |
When Encryption enabled | AES-256-GCM encrypt/decrypt. |
IWebhookService |
When Webhooks enabled | Publish webhook events. |
IFirebaseAppCheck |
When AppCheck enabled | Verify Firebase App Check tokens. |
Base Classes You Extend
| Class | Description |
|---|---|
BaseEndpoint<TRequest, TResponse> |
Your API endpoints. |
BaseEntity<T> |
Your database entities (with soft delete, timestamps). |
SubstratumEndpointSummary |
Your OpenAPI endpoint descriptions. |
Data Types
| Class | Description |
|---|---|
Result<T> |
Standard API response wrapper. |
PaginatedResult<T> |
Paginated query result with metadata. |
Unit |
Empty response type. |
PermissionDefinition |
Permission definition with code, name, group. |
DocGroupDefinition |
Document group for API organization. |
SpreadsheetFile |
Disposable file wrapper for spreadsheet exports. |
ImageResult |
Disposable wrapper for processed images. |
ImportResult<T> |
Spreadsheet import result with errors. |
ImportError |
Individual import error with row/column info. |
EmailMessage |
Email message with To, Cc, Bcc, Subject, Body, Attachments. |
EmailAttachment |
Email attachment with FileName, Content stream, ContentType. |
AuditLog |
EF entity for audit trail (used with IAuditableDbContext). |
AuditEntry |
Audit DTO for IAuditStore (advanced custom storage). |
PropertyChange |
Individual property change (old/new value). |
WebhookSubscription |
Webhook subscription with URL, event types, active status. |
WebhookDeliveryResult |
Webhook delivery result with status, attempt info. |
Full Configuration Reference
Below is the complete appsettings.json with every configurable option and its default value:
{
"ServerEnvironment": "Production",
"Serilog": {
"Using": ["Serilog.Sinks.Console", "Serilog.Enrichers.Sensitive"],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithThreadId",
{
"Name": "WithSensitiveDataMasking",
"Args": {
"options": {
"MaskValue": "*****",
"MaskProperties": [
{ "Name": "Password" },
{ "Name": "HashPassword" }
]
}
}
}
],
"Properties": { "Application": "MyApp" },
"WriteTo": [
{ "Name": "Console" }
]
},
"Cors": {
"AllowedOrigins": ["https://localhost:3000"],
"AllowedMethods": ["GET", "POST", "PUT", "DELETE", "PATCH"],
"AllowedHeaders": ["Content-Type", "Authorization", "X-APP-ID", "X-API-KEY"],
"AllowCredentials": true,
"MaxAgeSeconds": 600
},
"Authentication": {
"JwtBearer": {
"Enabled": true,
"Options": {
"SecretKey": "YOUR_SECRET_KEY_MUST_BE_AT_LEAST_32_CHARACTERS_LONG",
"Issuer": "http://localhost:5000",
"Audience": "MyApp",
"Expiration": "365.00:00:00",
"RefreshExpiration": "7.00:00:00",
"ClockSkew": "00:02:00",
"RequireHttpsMetadata": true
}
},
"Cookie": {
"Enabled": true,
"Options": {
"Scheme": "Cookies",
"CookieName": ".Substratum.Auth",
"Expiration": "365.00:00:00",
"SlidingExpiration": true,
"Secure": true,
"HttpOnly": true,
"SameSite": "Lax",
"AppIdHeaderName": "X-APP-ID"
}
},
"BasicAuthentication": {
"Enabled": false,
"Options": {
"Realm": "MyApp"
}
},
"AccessKeyAuthentication": {
"Enabled": false,
"Options": {
"Realm": "MyApp",
"KeyName": "X-API-KEY"
}
}
},
"EntityFramework": {
"Default": {
"Provider": "Npgsql",
"ConnectionString": "Host=localhost;Database=mydb;Username=postgres;Password=password",
"CommandTimeoutSeconds": 30,
"EnableSeeding": true,
"Logging": {
"EnableDetailedErrors": true,
"EnableSensitiveDataLogging": true
},
"RetryPolicy": {
"Enabled": true,
"Options": {
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
}
},
"SecondLevelCache": {
"Enabled": true,
"Options": {
"KeyPrefix": "EF_",
"Provider": "Memory"
}
}
}
},
"ErrorHandling": {
"IncludeExceptionDetails": true
},
"Localization": {
"DefaultCulture": "en"
},
"OpenApi": {
"Enabled": true,
"Options": {
"Servers": [
{
"Url": "https://localhost:5000",
"Description": "Local Development"
}
]
}
},
"StaticFiles": {
"Enabled": false,
"Options": {
"RootPath": "wwwroot",
"RequestPath": "",
"ContentTypeMappings": {
".custom": "application/x-custom-type"
}
}
},
"HealthChecks": {
"Enabled": true,
"Options": {
"Path": "/healthz"
}
},
"Minio": {
"Enabled": false,
"Options": {
"Endpoint": "play.min.io",
"Region": "us-east-1",
"Secure": true,
"AccessKey": "YOUR_ACCESS_KEY",
"SecretKey": "YOUR_SECRET_KEY"
}
},
"Aws": {
"S3": {
"Enabled": false,
"Options": {
"Endpoint": null,
"Region": "us-east-1",
"ForcePathStyle": false,
"AccessKey": "YOUR_ACCESS_KEY",
"SecretKey": "YOUR_SECRET_KEY"
}
},
"SecretsManager": {
"Enabled": false,
"Options": {
"Region": "us-east-1",
"SecretArns": ["YOUR_SECRET_ARN"],
"ServiceUrl": null,
"AccessKey": "YOUR_ACCESS_KEY",
"SecretKey": "YOUR_SECRET_KEY"
}
}
},
"Azure": {
"BlobStorage": {
"Enabled": false,
"Options": {
"ConnectionString": "YOUR_CONNECTION_STRING"
}
}
},
"Firebase": {
"Messaging": {
"Enabled": false,
"Options": {
"Credential": "BASE_64_ENCODED_SERVICE_ACCOUNT_JSON"
}
},
"AppCheck": {
"Enabled": false,
"Options": {
"ProjectId": "YOUR_FIREBASE_PROJECT_ID",
"ProjectNumber": "YOUR_FIREBASE_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
}
}
}
},
"Spreadsheet": {
"Enabled": false,
"Options": {
"LicenseType": "NonCommercial",
"DefaultDateFormat": "yyyy-MM-dd",
"DefaultNumberFormat": "#,##0.00",
"DefaultFont": {
"Name": "Calibri",
"Size": 11
},
"HeaderStyle": {
"Bold": true,
"FontSize": 12,
"BackgroundColor": "#2B5797",
"FontColor": "#FFFFFF",
"FreezeHeader": true
},
"Csv": {
"Delimiter": ",",
"Encoding": "UTF-8",
"IncludeHeader": true,
"DateFormat": "yyyy-MM-dd",
"NullValue": ""
},
"RightToLeft": false,
"MaxExportRows": 100000,
"TemplatePath": "templates",
"AutoFitColumns": true,
"AutoFitMaxWidth": 60,
"DefaultColumnWidth": 15
}
},
"Email": {
"Enabled": false,
"Options": {
"Host": "smtp.example.com",
"Port": 587,
"Username": "",
"Password": "",
"FromAddress": "noreply@example.com",
"FromName": "My App",
"UseSsl": true,
"TimeoutMs": 30000
}
},
"BackgroundJobs": {
"Enabled": false,
"Options": {
"MaxConcurrency": 1,
"QueueCapacity": 100
}
},
"AuditLogging": {
"Enabled": false,
"Options": {
"IncludePropertyChanges": true
}
},
"Encryption": {
"Enabled": false,
"Options": {
"Key": "BASE64_ENCODED_32_BYTE_KEY",
"SecondaryKey": null
}
},
"Webhooks": {
"Enabled": false,
"Options": {
"Secret": "YOUR_WEBHOOK_SECRET_MIN_16_CHARS",
"MaxRetries": 3,
"RetryDelaySeconds": 5,
"TimeoutSeconds": 30,
"QueueCapacity": 100
}
}
}
License
Substratum is available under the MIT License. See LICENSE for details.
EPPlus (used internally for spreadsheet functionality) requires its own license — choose "NonCommercial" for open source projects or "Commercial" with a license key for commercial use.
| 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.18.6)
- AWSSDK.SecretsManager (>= 4.0.4.6)
- 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)
- EPPlus (>= 8.4.2)
- FirebaseAdmin (>= 3.4.0)
- FluentValidation (>= 12.1.1)
- Kralizek.Extensions.Configuration.AWSSecretsManager (>= 1.7.0)
- MailKit (>= 4.14.1)
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 10.0.3)
- Microsoft.AspNetCore.OpenApi (>= 10.0.3)
- Microsoft.EntityFrameworkCore (>= 10.0.3)
- Microsoft.EntityFrameworkCore.InMemory (>= 10.0.3)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.3)
- Microsoft.EntityFrameworkCore.Sqlite (>= 10.0.3)
- Microsoft.EntityFrameworkCore.SqlServer (>= 10.0.3)
- Microsoft.Extensions.Caching.StackExchangeRedis (>= 10.0.3)
- Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore (>= 10.0.3)
- Minio (>= 7.0.0)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 10.0.0)
- Otp.NET (>= 1.4.1)
- Scalar.AspNetCore (>= 2.12.40)
- 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 |
|---|---|---|
| 1.0.0-beta.122 | 0 | 2/16/2026 |
| 1.0.0-beta.110 | 30 | 2/15/2026 |
| 1.0.0-beta.107 | 32 | 2/15/2026 |
| 1.0.0-beta.104 | 29 | 2/14/2026 |
| 1.0.0-beta.103 | 24 | 2/14/2026 |
| 1.0.0-beta.102 | 28 | 2/14/2026 |
| 1.0.0-beta.100 | 27 | 2/14/2026 |
| 1.0.0-beta.96 | 24 | 2/14/2026 |
| 1.0.0-beta.94 | 31 | 2/14/2026 |
| 1.0.0-beta.90 | 24 | 2/14/2026 |
| 1.0.0-beta.80 | 32 | 2/14/2026 |
| 1.0.0-beta.71 | 33 | 2/13/2026 |
| 1.0.0-beta.70 | 38 | 2/10/2026 |
| 1.0.0-beta.68 | 37 | 2/10/2026 |
| 1.0.0-beta.67 | 27 | 2/10/2026 |
| 1.0.0-beta.66 | 27 | 2/10/2026 |
| 1.0.0-beta.65 | 33 | 2/10/2026 |
| 1.0.0-beta.64 | 34 | 2/10/2026 |
| 1.0.0-beta.63 | 32 | 2/10/2026 |
| 1.0.0-beta.62 | 29 | 2/10/2026 |