Substratum.Generator
1.0.0-beta.96
See the version list below for details.
dotnet add package Substratum.Generator --version 1.0.0-beta.96
NuGet\Install-Package Substratum.Generator -Version 1.0.0-beta.96
<PackageReference Include="Substratum.Generator" Version="1.0.0-beta.96"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
<PackageVersion Include="Substratum.Generator" Version="1.0.0-beta.96" />
<PackageReference Include="Substratum.Generator"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
paket add Substratum.Generator --version 1.0.0-beta.96
#r "nuget: Substratum.Generator, 1.0.0-beta.96"
#:package Substratum.Generator@1.0.0-beta.96
#addin nuget:?package=Substratum.Generator&version=1.0.0-beta.96&prerelease
#tool nuget:?package=Substratum.Generator&version=1.0.0-beta.96&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
- Configuration
- Core Library
- Authentication
- Permissions
- Document Groups
- Entity Framework
- OpenAPI Documentation
- Localization
- Spreadsheet
- Quick Start
- Attributes Reference
- 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
- Source Generator Diagnostics
- Spreadsheet Configuration
- Cloud Storage
- Image Processing
- Firebase
- Infrastructure
- Source Generators
- CLI Tools
- Full Configuration Reference
- Interfaces to Implement
- License
Packages
| Package | Description | Target |
|---|---|---|
| Substratum | Core runtime library — middleware, auth, EF Core, cloud integrations, spreadsheet engine | net10.0 |
| Substratum.Generator | Roslyn source generators — permissions, reflection, service registration, endpoint summaries, document groups, sheet metadata | netstandard2.0 |
| Substratum.Tools | CLI tool (dotnet sub) — scaffold projects, endpoints, entities, and manage migrations |
net10.0 |
Quick Start
1. Install the CLI
dotnet tool install -g Substratum.Tools
2. Create a new project
dotnet sub new webapp --name MyApp
This scaffolds a complete project with authentication, permissions, EF Core, localization, and health checks — ready to run.
3. Add packages to an existing project
<PackageReference Include="Substratum" Version="1.0.0-beta.70" />
<PackageReference Include="Substratum.Generator" Version="1.0.0-beta.70" />
4. Run the app
await SubstratumApp.RunAsync(args);
All configuration lives in appsettings.json. No boilerplate code needed — the source generator handles wiring.
Configuration
Substratum supports two configuration approaches. appsettings.json is recommended — it keeps configuration separate from code and supports per-environment overrides.
appsettings.json (Recommended)
{
"ServerEnvironment": "Development",
"Authentication": {
"JwtBearer": {
"Enabled": true,
"Options": {
"SecretKey": "your-256-bit-secret-key-must-be-long-enough",
"Issuer": "https://myapp.com",
"Audience": "myapp-api",
"Expiration": "01:00:00",
"RefreshExpiration": "7.00:00:00",
"ClockSkew": "00:02:00",
"RequireHttpsMetadata": true
}
},
"Cookie": {
"Enabled": true,
"Options": {
"CookieName": ".MyApp.Auth",
"Expiration": "365.00:00:00",
"SlidingExpiration": true,
"Secure": true,
"HttpOnly": true,
"SameSite": "Lax"
}
}
},
"EntityFramework": {
"Default": {
"Provider": "Npgsql",
"ConnectionString": "Host=localhost;Database=myapp;Username=postgres;Password=password"
}
},
"Cors": {
"AllowedOrigins": ["https://localhost:3000"]
}
}
C# Configuration
Override or supplement appsettings.json in code:
await SubstratumApp.RunAsync(args, options =>
{
options.Authentication.JwtBearer.Enabled = true;
options.Authentication.JwtBearer.Options = new JwtBearerOptions
{
SecretKey = "your-256-bit-secret-key",
Issuer = "https://myapp.com",
Audience = "myapp-api"
};
options.EntityFramework["Default"].Provider = EntityFrameworkProviders.Npgsql;
options.EntityFramework["Default"].ConnectionString = "Host=localhost;Database=myapp;...";
// Register custom services
options.Services.AddScoped<IMyService, MyService>();
});
Server Environment
Set the server environment via appsettings.json or in code. Defaults to Production.
{
"ServerEnvironment": "Development"
}
Available values: Development, Staging, UAT, Production.
await SubstratumApp.RunAsync(args, options =>
{
if (options.ServerEnvironment.IsDevelopment())
{
options.EntityFramework["Default"].Logging.EnableSensitiveDataLogging = true;
}
// Access IConfiguration directly
var customValue = options.Configuration["MyCustomKey"];
});
| Member | Description |
|---|---|
ServerEnvironment |
Current environment (Development, Staging, UAT, Production) |
.IsDevelopment() |
Extension — returns true when Development |
.IsStaging() |
Extension — returns true when Staging |
.IsUAT() |
Extension — returns true when UAT |
.IsProduction() |
Extension — returns true when Production |
Configuration |
Access IConfiguration for custom settings |
Substratum (Core Library)
Endpoints
All endpoints inherit from BaseEndpoint<TRequest, TResponse> and return a standardized Result<TResponse>.
public class GetOrderEndpoint : BaseEndpoint<GetOrderRequest, OrderResponse>
{
public override void Configure()
{
Get("/orders/{id}");
PermissionsAny(AppPermissions.OrdersGet);
}
public override async Task<Result<OrderResponse>> ExecuteAsync(GetOrderRequest req, CancellationToken ct)
{
var order = await db.Orders.FindAsync(req.Id, ct);
if (order is null)
return Failure(404, "OrderNotFound");
return Success("OrderRetrieved", new OrderResponse { Id = order.Id, Total = order.Total });
}
}
Result<T> wraps every response:
| Property | Type | Description |
|---|---|---|
Code |
int |
0 for success, 1 for failure |
Message |
string |
Human-readable (localized) message |
Data |
T? |
Response payload |
Errors |
IReadOnlyList<string>? |
Validation or error details |
For endpoints that return no data, use Unit as the response type.
Pagination
public class ListOrdersEndpoint : BaseEndpoint<ListOrdersRequest, PaginatedResult<OrderResponse>>
{
public override async Task<Result<PaginatedResult<OrderResponse>>> ExecuteAsync(
ListOrdersRequest req, CancellationToken ct)
{
var result = await PaginatedResult<OrderResponse>.CreateAsync(
db.Orders.Select(o => new OrderResponse { Id = o.Id, Total = o.Total }),
req.PageNumber,
req.PageSize,
ct
);
return Success("OrdersRetrieved", result);
}
}
PaginatedResult<T> includes PageNumber, TotalPages, TotalCount, Items, HasPreviousPage, and HasNextPage.
Base Entity
All domain entities inherit from BaseEntity<T> which provides built-in audit fields:
public sealed class Order : BaseEntity<Guid>
{
public decimal Total { get; set; }
public string Status { get; set; }
}
| Property | Type | Description |
|---|---|---|
Id |
T |
Primary key |
CreatedAt |
DateTimeOffset |
Auto-set on creation |
UpdatedAt |
DateTimeOffset |
Auto-set on update |
IsDeleted |
bool |
Soft delete flag |
DeletedAt |
DateTimeOffset? |
Soft delete timestamp |
Authentication
Substratum supports four authentication schemes out of the box. Enable any combination — the framework automatically configures a policy scheme that routes to the correct handler.
JWT Bearer
appsettings.json:
{
"Authentication": {
"JwtBearer": {
"Enabled": true,
"Options": {
"SecretKey": "your-256-bit-secret-key-must-be-long-enough",
"Issuer": "https://myapp.com",
"Audience": "myapp-api",
"Expiration": "01:00:00",
"RefreshExpiration": "7.00:00:00",
"ClockSkew": "00:02:00",
"RequireHttpsMetadata": true
}
}
}
}
C# configuration:
options.Authentication.JwtBearer.Enabled = true;
options.Authentication.JwtBearer.Options = new JwtBearerOptions
{
SecretKey = "your-256-bit-secret-key",
Issuer = "https://myapp.com",
Audience = "myapp-api",
Expiration = TimeSpan.FromHours(1),
RefreshExpiration = TimeSpan.FromDays(7),
ClockSkew = TimeSpan.FromMinutes(2),
RequireHttpsMetadata = true
};
Create tokens with IJwtBearer:
// Access token only (no refresh token store needed)
var (accessToken, sessionId, expiration) = jwtBearer.CreateToken(userId);
Refresh Tokens
For token rotation workflows, implement IRefreshTokenStore and use CreateTokenPairAsync / RefreshAsync:
public class RefreshTokenStore : IRefreshTokenStore
{
public Task StoreAsync(Guid userId, Guid sessionId, string tokenHash,
DateTimeOffset expiration, CancellationToken ct)
{
// Store the hashed refresh token in your database
}
public Task<RefreshTokenValidationResult?> ValidateAndRevokeAsync(string tokenHash, CancellationToken ct)
{
// Find token by hash, revoke it, return userId/sessionId (or null if invalid/expired)
}
public Task RevokeBySessionAsync(Guid sessionId, CancellationToken ct)
{
// Revoke all refresh tokens for a session (logout)
}
public Task RevokeAllAsync(Guid userId, CancellationToken ct)
{
// Revoke all refresh tokens for a user (logout everywhere)
}
}
Issue and refresh token pairs:
// Issue access + refresh token pair
var (accessToken, refreshToken, sessionId, accessExpiration, refreshExpiration) =
await jwtBearer.CreateTokenPairAsync(userId, ct);
// Refresh — atomically revokes the old refresh token and issues a new pair
var result = await jwtBearer.RefreshAsync(refreshToken, ct);
if (result is null)
return Failure(401, "InvalidRefreshToken");
var (newAccessToken, newRefreshToken, newAccessExpiration, newRefreshExpiration) = result.Value;
Refresh tokens are SHA256-hashed before storage — only hashes are persisted, never raw tokens. RefreshAsync performs atomic token rotation: the old token is revoked and a new pair is issued in a single operation.
Cookie
appsettings.json:
{
"Authentication": {
"Cookie": {
"Enabled": true,
"Options": {
"Scheme": "Cookies",
"CookieName": ".MyApp.Auth",
"Expiration": "365.00:00:00",
"SlidingExpiration": true,
"Secure": true,
"HttpOnly": true,
"SameSite": "Lax"
}
}
}
}
C# configuration:
options.Authentication.Cookie.Enabled = true;
options.Authentication.Cookie.Options = new CookieOptions
{
CookieName = "auth_token",
Expiration = TimeSpan.FromHours(1),
SlidingExpiration = true,
SameSite = SameSiteMode.Strict
};
Sign in/out with ICookieAuth:
var (sessionId, expiration) = await cookieAuth.SignInAsync(HttpContext, userId, ct);
await cookieAuth.SignOutAsync(HttpContext, ct);
Basic Authentication
appsettings.json:
{
"Authentication": {
"BasicAuthentication": {
"Enabled": true,
"Options": {
"Realm": "MyAPI"
}
}
}
}
C# configuration:
options.Authentication.BasicAuthentication.Enabled = true;
options.Authentication.BasicAuthentication.Options = new BasicAuthenticationOptions
{
Realm = "MyAPI"
};
Implement IBasicAuthValidator:
public class BasicAuthValidator : IBasicAuthValidator
{
public async Task<(bool Result, string UserId, string SessionId)> ValidateAsync(
HttpContext context, string username, string password, CancellationToken ct)
{
var user = await db.Users.FirstOrDefaultAsync(u => u.Email == username, ct);
if (user is null || !passwordHasher.VerifyHashedPassword(user.PasswordHash, password, out _))
return (false, "", "");
return (true, user.Id.ToString("N"), Guid.CreateVersion7().ToString("N"));
}
}
Access Key
appsettings.json:
{
"Authentication": {
"AccessKeyAuthentication": {
"Enabled": true,
"Options": {
"Realm": "MyAPI",
"KeyName": "X-API-KEY"
}
}
}
}
C# configuration:
options.Authentication.AccessKeyAuthentication.Enabled = true;
options.Authentication.AccessKeyAuthentication.Options = new AccessKeyAuthenticationOptions
{
KeyName = "X-API-KEY",
Realm = "MyAPI"
};
Implement IAccessKeyValidator:
public class AccessKeyValidator : IAccessKeyValidator
{
public async Task<(bool Result, string UserId, string SessionId)> ValidateAsync(
HttpContext context, string accessKey, CancellationToken ct)
{
var key = await db.AccessKeys.FirstOrDefaultAsync(k => k.Key == accessKey && k.IsActive, ct);
if (key is null) return (false, "", "");
return (true, key.UserId.ToString("N"), Guid.CreateVersion7().ToString("N"));
}
}
App-Scoped Authentication
Host multiple apps on the same domain with isolated sessions. Each app gets its own cookie (e.g., .auth.app1, .auth.app2) and/or JWT tokens scoped by an aid claim. The client passes an X-APP-ID header to identify the app context.
1. Implement IAppResolver to validate app IDs dynamically (e.g., from a database):
public class AppResolver : IAppResolver
{
public async Task<bool> ValidateAsync(string appId, CancellationToken ct)
{
return await db.Apps.AnyAsync(a => a.Id == appId && a.IsActive, ct);
}
}
2. Use app-scoped sign-in — pass appId to cookie or JWT methods:
// Cookie — writes ".auth.app1" cookie (based on X-APP-ID header)
var (sessionId, expiration) = await cookieAuth.SignInAsync(HttpContext, userId, "app1", ct);
// JWT — embeds "aid" claim in the token
var (accessToken, sessionId, expiration) = jwtBearer.CreateToken(userId, "app1");
// Token pair with app scope
var result = await jwtBearer.CreateTokenPairAsync(userId, "app1", ct);
3. Access the app ID downstream via ICurrentUser.AppId:
var appId = currentUser.AppId; // "app1" or null for global scope
Without appId — calling SignInAsync(ctx, userId) or CreateToken(userId) without the appId parameter works exactly as before (global scope, no app isolation).
Without IAppResolver — if no implementation exists, the feature is completely inert. No header checking, no cookie renaming, standard behavior.
The header name is configurable:
options.Authentication.Cookie.Options.AppIdHeaderName = "X-APP-ID"; // default
Two-Factor Authentication (TOTP)
Substratum provides ITotpProvider — a built-in TOTP service compatible with Google Authenticator, Authy, and other authenticator apps.
Enrollment:
// Generate a secret and QR code URI for the user
var secret = totpProvider.GenerateSecret();
var qrCodeUri = totpProvider.GenerateQrCodeUri(secret, user.Email, "MyApp");
// Store secret in your database, return qrCodeUri to the client for QR display
Verification:
// Validate a 6-digit code from the authenticator app
var isValid = totpProvider.ValidateCode(user.TotpSecret, request.Code);
Login flow with 2FA:
- Validate password
- If 2FA not enabled → issue token via
jwtBearer.CreateToken()orCreateTokenPairAsync() - If 2FA enabled → return a pending state (no token yet)
- User submits TOTP code → validate with
totpProvider.ValidateCode()→ issue token
The framework provides the TOTP utility; you implement the enrollment endpoints and login flow in your own endpoints since these are app-specific.
Supporting Interfaces
| Interface | Purpose | Required By |
|---|---|---|
ISessionValidator |
Validates active sessions server-side | JWT, Cookie |
IPermissionHydrator |
Loads user permissions into claims | All schemes |
IRefreshTokenStore |
Stores and validates hashed refresh tokens | JWT (when using refresh tokens) |
IAppResolver |
Validates app IDs for multi-app session isolation | App-scoped auth |
IPasswordHasher |
Hashes and verifies passwords (PBKDF2, 600k iterations) | Built-in service |
ITotpProvider |
TOTP 2FA — generate secrets, QR URIs, validate codes | Built-in service |
ICurrentUser |
Access current user's ID and app ID | Built-in service |
IImageService |
Resize, compress to WebP, generate BlurHash | Built-in service |
Permissions
Define permissions as static fields in a partial class implementing IPermissionRegistry. The source generator auto-generates Definitions(), Parse(), and TryParse() methods.
public partial class AppPermissions : IPermissionRegistry
{
public static readonly PermissionDefinition OrdersGet;
public static readonly PermissionDefinition OrdersCreate;
public static readonly PermissionDefinition OrdersEdit;
public static readonly PermissionDefinition OrdersDelete;
public static readonly PermissionDefinition UsersGet;
public static readonly PermissionDefinition UsersCreate;
}
The generator produces:
- Code: unique short code derived from the field name (e.g.
A1B2) - Name: snake_case from the field name (e.g.
orders_get) - DisplayName: human-readable (e.g.
Get) - Group: inferred from the field name prefix (e.g.
Ordersgroup)
Use permissions in endpoints:
public override void Configure()
{
Get("/orders/{id}");
PermissionsAny(AppPermissions.OrdersGet); // user needs ANY of these
PermissionsAll(AppPermissions.OrdersGet); // user needs ALL of these
}
Implement IPermissionHydrator to load permissions from your database into claims:
public class PermissionHydrator : IPermissionHydrator
{
public async Task HydrateAsync(IServiceProvider sp, ClaimsPrincipal principal, CancellationToken ct)
{
var userId = principal.FindFirstValue(SubstratumClaimsTypes.UserId);
var db = sp.GetRequiredService<AppDbContext>();
var codes = await db.UserPermissions
.Where(up => up.UserId == Guid.Parse(userId))
.Select(up => up.Permission.Code)
.ToListAsync(ct);
var identity = (ClaimsIdentity)principal.Identity!;
foreach (var code in codes)
identity.AddClaim(new Claim("permissions", code));
}
}
Document Groups
Organize your API into separate OpenAPI documents — each with its own Scalar UI page and optional authentication.
Define groups in Common/Security/DocGroups.cs:
public partial class DocGroups : IDocGroupRegistry
{
// Default group — shows ALL endpoints at /docs (no tag filtering required)
public static readonly DocGroupDefinition Default =
new("API Documentation", "docs", isDefault: true);
// Filtered groups — show only tagged endpoints
public static readonly DocGroupDefinition Mobile =
new("Mobile API", "mobile");
public static readonly DocGroupDefinition Admin =
new("Admin API", "admin", AppPermissions.AdminDocsAccess); // protected with permission
}
Use in endpoints:
public override void Configure()
{
Get("/orders");
DocGroup(DocGroups.Mobile);
}
This creates:
/docs— Scalar UI showing all endpoints (the Default group)/mobile— Scalar UI showing only Mobile API endpoints/admin— Scalar UI showing only Admin API endpoints (requires API key)
How it works:
- The
Defaultgroup (isDefault: true) shows all endpoints regardless of tags. Endpoints don't need to callDocGroup()to appear in it. If no Default group is defined, there is no "all endpoints" documentation page. - Non-default groups only show endpoints explicitly tagged via
DocGroup(). - Protected groups (with a
PermissionDefinition) use Basic Auth — the username is the access key, the password is empty. The browser's native auth dialog prompts the user. Permissions are validated throughIAccessKeyValidatorandIPermissionHydrator.
Entity Framework
Substratum supports one or more DbContext registrations. Each context is configured under a named key in appsettings.json:
appsettings.json:
{
"EntityFramework": {
"Default": {
"Provider": "Npgsql",
"ConnectionString": "Host=localhost;Database=myapp;Username=postgres;Password=password",
"CommandTimeoutSeconds": 30,
"EnableSeeding": true,
"Logging": {
"EnableDetailedErrors": true,
"EnableSensitiveDataLogging": true
}
}
}
}
Supported providers: PostgreSQL (Npgsql), SQL Server (SqlServer), SQLite (Sqlite)
Single DbContext
A single DbContext works out of the box — no attribute is needed. It maps to the "Default" config key automatically:
public class AppDbContext : DbContext { ... }
Multiple DbContexts
When using multiple DbContexts, annotate each one with [DbContextName] to map it to its config key:
[DbContextName("Default")]
public class AppDbContext : DbContext { ... }
[DbContextName("Blockchain")]
public class BlockchainDbContext : DbContext { ... }
{
"EntityFramework": {
"Default": {
"Provider": "Npgsql",
"ConnectionString": "Host=localhost;Database=myapp;..."
},
"Blockchain": {
"Provider": "Npgsql",
"ConnectionString": "Host=localhost;Database=blockchain;..."
}
}
}
Database Seeding
Implement IDbContextInitializer<TDbContext> to seed data on startup:
public class AppDbContextInitializer : IDbContextInitializer<AppDbContext>
{
public async Task SeedAsync(AppDbContext dbContext, CancellationToken ct = default)
{
// Seed initial data...
await dbContext.SaveChangesAsync(ct);
}
}
Each DbContext can have its own initializer. The source generator automatically wires them up.
Retry Policy
appsettings.json:
{
"EntityFramework": {
"Default": {
"RetryPolicy": {
"Enabled": true,
"Options": {
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
}
}
}
}
}
C# configuration:
options.EntityFramework["Default"].RetryPolicy.Enabled = true;
options.EntityFramework["Default"].RetryPolicy.Options = new RetryPolicyOptions
{
MaxRetryCount = 3,
MaxRetryDelaySeconds = 2
};
Second-Level Cache
appsettings.json:
{
"EntityFramework": {
"Default": {
"SecondLevelCache": {
"Enabled": true,
"Options": {
"KeyPrefix": "EF_",
"Provider": "Memory"
}
}
}
}
}
C# configuration:
options.EntityFramework["Default"].SecondLevelCache.Enabled = true;
options.EntityFramework["Default"].SecondLevelCache.Options = new SecondLevelCacheOptions
{
Provider = SecondLevelCacheProviders.Memory, // or Redis
KeyPrefix = "app:"
};
// Redis configuration
options.EntityFramework["Default"].SecondLevelCache.Options.Redis = new SecondLevelCacheRedisOptions
{
ConnectionString = "localhost:6379",
TimeoutSeconds = 300
};
CLI Tools
The sub CLI supports a --context (-c) flag for multi-context projects:
sub migrations add InitialCreate --context AppDbContext
sub migrations apply --context AppDbContext
sub migrations script --context AppDbContext
When omitted, the default context is used.
OpenAPI Documentation
appsettings.json:
{
"OpenApi": {
"Enabled": true,
"Options": {
"Servers": [
{ "Url": "https://api.example.com", "Description": "Production" },
{ "Url": "https://staging.example.com", "Description": "Staging" }
]
}
}
}
C# configuration:
options.OpenApi.Enabled = true;
options.OpenApi.Options.Servers = new[]
{
new OpenApiServerOptions { Url = "https://api.example.com", Description = "Production" },
new OpenApiServerOptions { Url = "https://staging.example.com", Description = "Staging" }
};
Substratum generates Scalar UI at /docs (or at document group URLs). Features include:
- Automatic permission documentation on each endpoint
- Accept-Language header parameter for localized APIs
- Dark mode theme
Localization
appsettings.json:
{
"Localization": {
"DefaultCulture": "en"
}
}
C# configuration:
options.Localization.DefaultCulture = "en";
options.Localization.SupportedCultures = ["en", "ar", "fr"];
options.Localization.ResourceSource = typeof(SharedResource);
The framework reads Accept-Language headers and localizes validation messages, error responses, and OpenAPI descriptions.
Spreadsheet
Source generator-powered Excel/CSV spreadsheet engine with zero runtime reflection. Uses EPPlus internally — never exposed in the public API. All sheet metadata (column mapping, styles, formatting) is resolved at compile time via the SpreadsheetGenerator and registered through [ModuleInitializer].
Spreadsheet Quick Start
1. Enable in appsettings.json:
{
"Spreadsheet": {
"Enabled": true
}
}
Or in C#:
options.Spreadsheet.Enabled = true;
2. Decorate your export class with [Sheet]:
[Sheet("Employees", AutoFilter = true, FreezeHeader = true)]
public class EmployeeExport
{
[Column("Full Name", Order = 0)]
[HeaderStyle(Bold = true, BackgroundColor = "#2B5797", FontColor = "#FFFFFF")]
public string Name { get; set; } = "";
[Column("Department", Order = 1)]
[Dropdown("Engineering", "Sales", "Marketing", "HR")]
public string Department { get; set; } = "";
[Column("Salary", Order = 2, Format = "#,##0.00")]
[ConditionalFormat("> 100000", BackgroundColor = "#C6EFCE")]
[ConditionalFormat("< 50000", BackgroundColor = "#FFC7CE", FontColor = "#9C0006")]
public decimal Salary { get; set; }
[Column("Start Date", Order = 3)]
public DateOnly StartDate { get; set; }
[Column("Active", Order = 4)]
[BooleanFormat(TrueValue = "Active", FalseValue = "Inactive")]
public bool IsActive { get; set; }
[IgnoreColumn]
public string InternalNotes { get; set; } = "";
}
3. Inject ISpreadsheet and export:
app.MapGet("/export", (ISpreadsheet spreadsheet) =>
{
var data = GetEmployees();
var file = spreadsheet.ToFile<EmployeeExport>(data, "employees.xlsx");
return Results.File(file.GetStream(), file.ContentType, file.FileName);
});
The source generator creates a EmployeeExportSheetMetadata class implementing ISheetMetadata<EmployeeExport> with direct property access — no reflection at runtime.
Attributes Reference
Sheet Attribute
Applied to a class to define sheet-level settings. The source generator discovers all classes with [Sheet].
[Sheet("Report", AutoFilter = true, FreezeHeader = true, RightToLeft = false, TabColor = "#FF0000")]
public class MyExport { ... }
| Property | Type | Default | Description |
|---|---|---|---|
Name |
string? |
Class name (stripped of common suffixes like Export, Sheet, Row, Data, Dto, Model, Record, Item, Entry) |
Sheet tab name |
RightToLeft |
bool |
false |
Right-to-left layout |
AutoFilter |
bool |
false |
Enable Excel auto-filter on all columns |
FreezeHeader |
bool |
false |
Freeze the first row |
TabColor |
string? |
null |
Tab color as hex (e.g., "#FF0000") |
Protection |
SheetProtectionMode |
None |
Sheet protection mode (None, Password, Sealed) |
Password |
string? |
null |
Password for Password protection mode |
AllowSort |
bool |
true |
Allow sorting when protected |
AllowFilter |
bool |
true |
Allow filtering when protected |
AllowSelectLockedCells |
bool |
true |
Allow selecting locked cells when protected |
AllowSelectUnlockedCells |
bool |
true |
Allow selecting unlocked cells when protected |
Default sheet name derivation:
EmployeeExport→"Employee"OrderData→"Order"InvoiceSheet→"Invoice"MyReport→"MyReport"(no suffix to strip)
Column Attribute
Applied to a property to customize column mapping.
[Column("Full Name", Order = 0, Width = 25, Format = "@")]
public string Name { get; set; } = "";
| Property | Type | Default | Description |
|---|---|---|---|
Name |
string? |
Property name | Column header text |
Order |
int |
-1 (declaration order) |
Column position (0-based). -1 uses declaration order |
Width |
double |
-1 (auto/default) |
Column width in Excel units. -1 uses global DefaultColumnWidth |
Format |
string? |
null |
Excel number format (e.g., "#,##0.00", "yyyy-MM-dd", "@" for text) |
Hidden |
bool |
false |
Hide the column in output |
WrapText |
bool |
false |
Enable text wrapping in data cells |
Properties without [Column] are included with their property name as the header. Use [IgnoreColumn] to exclude.
HeaderStyle Attribute
Applied to a property to style that column's header cell.
[HeaderStyle(Bold = true, BackgroundColor = "#2B5797", FontColor = "#FFFFFF", FontSize = 14, Alignment = HorizontalAlignment.Center)]
public string Name { get; set; } = "";
| Property | Type | Default | Description |
|---|---|---|---|
Bold |
bool |
false |
Bold text |
Italic |
bool |
false |
Italic text |
Underline |
bool |
false |
Underline text |
BackgroundColor |
string? |
null |
Background color as hex (e.g., "#2B5797") |
FontColor |
string? |
null |
Font color as hex (e.g., "#FFFFFF") |
FontSize |
double |
-1 (inherit global) |
Font size in points |
FontName |
string? |
null |
Font family name |
Alignment |
HorizontalAlignment |
General |
General, Left, Center, Right |
When no [HeaderStyle] is specified, the global HeaderStyle from SpreadsheetOptions is used.
CellStyle Attribute
Applied to a property to style all data cells in that column. Same properties as [HeaderStyle].
[CellStyle(Bold = true, FontColor = "#006100", Alignment = HorizontalAlignment.Right)]
public decimal Total { get; set; }
| Property | Type | Default | Description |
|---|---|---|---|
Bold |
bool |
false |
Bold text |
Italic |
bool |
false |
Italic text |
Underline |
bool |
false |
Underline text |
BackgroundColor |
string? |
null |
Background color as hex |
FontColor |
string? |
null |
Font color as hex |
FontSize |
double |
-1 (inherit) |
Font size in points |
FontName |
string? |
null |
Font family name |
Alignment |
HorizontalAlignment |
General |
General, Left, Center, Right |
ConditionalFormat Attribute
Applied to a property to highlight cells based on their value. AllowMultiple — stack multiple conditions on the same column.
[ConditionalFormat("> 100000", BackgroundColor = "#C6EFCE", FontColor = "#006100")]
[ConditionalFormat("< 50000", BackgroundColor = "#FFC7CE", FontColor = "#9C0006", Bold = true)]
[ConditionalFormat("between 50000 and 100000", BackgroundColor = "#FFEB9C")]
public decimal Salary { get; set; }
| Property | Type | Default | Description |
|---|---|---|---|
When |
string |
(required) | Condition expression (see syntax below) |
BackgroundColor |
string? |
null |
Background color when condition is met |
FontColor |
string? |
null |
Font color when condition is met |
Bold |
bool |
false |
Bold text when condition is met |
Italic |
bool |
false |
Italic text when condition is met |
Condition expression syntax:
| Expression | Example | Description |
|---|---|---|
> value |
"> 100" |
Greater than |
>= value |
">= 100" |
Greater than or equal |
< value |
"< 50" |
Less than |
<= value |
"<= 50" |
Less than or equal |
== value |
"== 0" |
Equal |
!= value |
"!= 0" |
Not equal |
between X and Y |
"between 50 and 100" |
Between two values (inclusive) |
contains text |
"contains Error" |
Text contains substring |
not contains text |
"not contains Draft" |
Text does not contain substring |
== null |
"== null" |
Cell is empty/null |
!= null |
"!= null" |
Cell is not empty |
All expressions are parsed at compile time by the source generator — no runtime parsing overhead.
Dropdown Attribute
Applied to a property to add Excel data validation with a dropdown list.
Inline values:
[Dropdown("Engineering", "Sales", "Marketing", "HR", "Finance")]
public string Department { get; set; } = "";
Enum type:
[Dropdown(typeof(OrderStatus))]
public string Status { get; set; } = "";
The source generator reads the enum members at compile time and embeds them as string values.
BooleanFormat Attribute
Applied to a bool property to display custom text instead of true/false.
[BooleanFormat(TrueValue = "Active", FalseValue = "Inactive")]
public bool IsActive { get; set; }
[BooleanFormat(TrueValue = "Yes", FalseValue = "No")]
public bool IsVerified { get; set; }
| Property | Type | Default | Description |
|---|---|---|---|
TrueValue |
string |
"Yes" |
Display text when true |
FalseValue |
string |
"No" |
Display text when false |
The source generator reports SS005 if applied to a non-bool property.
Formula Attribute
Applied to a property to insert an Excel formula instead of a data value. Use {row} as a placeholder for the current row number.
[Formula("=C{row}*D{row}", Format = "#,##0.00")]
public decimal Total { get; set; }
[Formula("=IF(E{row}>100000,\"High\",\"Normal\")")]
public string Priority { get; set; } = "";
| Property | Type | Default | Description |
|---|---|---|---|
Expression |
string |
(required) | Excel formula. {row} is replaced with the actual row number |
Format |
string? |
null |
Number format for the formula result |
The source generator reports SS004 if applied to a non-string property (formulas should use string type since the cell value comes from Excel).
SummaryRow Attribute
Applied to a class to add an automatic summary row at the bottom of the sheet. AllowMultiple — add multiple summary rows.
[Sheet("Sales Report")]
[SummaryRow(Label = "Total", LabelColumn = "Name", Function = SummaryFunction.Sum, Columns = new[] { "Amount", "Tax" })]
[SummaryRow(Label = "Average", LabelColumn = "Name", Function = SummaryFunction.Average, Columns = new[] { "Amount" })]
public class SalesExport
{
[Column("Name")]
public string Name { get; set; } = "";
[Column("Amount", Format = "#,##0.00")]
public decimal Amount { get; set; }
[Column("Tax", Format = "#,##0.00")]
public decimal Tax { get; set; }
}
| Property | Type | Default | Description |
|---|---|---|---|
Label |
string |
"Total" |
Label text in the summary row |
LabelColumn |
string? |
null |
Column name where the label is placed. If null, uses the first column |
Function |
SummaryFunction |
Sum |
Aggregation function: Sum, Average, Count, Min, Max |
Columns |
string[] |
[] |
Column names (matching [Column] names or property names) to apply the function to |
The source generator reports SS007 if Columns or LabelColumn reference a column name that doesn't exist.
MergeWith Attribute
Applied to a property to merge adjacent cells with the same value in this column with cells in another column.
[MergeWith("Department")]
public string Category { get; set; } = "";
| Property | Type | Default | Description |
|---|---|---|---|
PropertyName |
string |
(required) | Target property name to merge with |
The source generator reports SS009 if the target property name doesn't exist on the class.
IgnoreColumn Attribute
Marker attribute — the property is excluded from both export and import.
[IgnoreColumn]
public string InternalNotes { get; set; } = "";
[IgnoreColumn]
public DateTime CachedAt { get; set; }
The source generator reports SS010 if both [Column] and [IgnoreColumn] are applied to the same property.
SearchableIndex Attribute
Marker attribute — creates an Excel named range for the column, useful for VLOOKUP/INDEX-MATCH formulas in other sheets.
[SearchableIndex]
[Column("Employee ID")]
public string EmployeeId { get; set; } = "";
The named range is created as {SheetName}_{PropertyName} (e.g., Employees_EmployeeId).
Excel Export
Single sheet export:
// As bytes
byte[] bytes = spreadsheet.Export<EmployeeExport>(data);
// As stream
Stream stream = spreadsheet.ExportAsStream<EmployeeExport>(data);
// As file (with filename and content type)
SpreadsheetFile file = spreadsheet.ToFile<EmployeeExport>(data, "employees.xlsx");
return Results.File(file.GetStream(), file.ContentType, file.FileName);
// With overrides
byte[] bytes = spreadsheet.Export<EmployeeExport>(data, overrides =>
{
overrides.SheetName = "Custom Name";
overrides.HiddenColumns = ["InternalId", "AuditTrail"];
});
CSV Export
All CSV output is RFC 4180 compliant — proper escaping of delimiters, quotes, and newlines.
// As string
string csv = spreadsheet.ExportCsvAsString<EmployeeExport>(data);
// As bytes
byte[] bytes = spreadsheet.ExportCsv<EmployeeExport>(data);
// As stream
Stream stream = spreadsheet.ExportCsvAsStream<EmployeeExport>(data);
// As file
SpreadsheetFile file = spreadsheet.ToCsvFile<EmployeeExport>(data, "employees.csv");
CSV export respects:
- Column order from
[Column(Order = ...)] - Hidden columns are excluded
[BooleanFormat]values are used instead oftrue/false- DateTime formatting from options
- Custom delimiter, encoding, and null value from
SpreadsheetOptions.Csv
Multi-Sheet Workbook
Build workbooks with multiple typed sheets, DataTable sheets, and workbook metadata.
SpreadsheetFile file = spreadsheet.CreateWorkbook()
.AddSheet<EmployeeExport>(employees)
.AddSheet<DepartmentExport>(departments)
.AddSheet<SalesExport>(sales)
.WithMetadata(m =>
{
m.Author = "HR System";
m.Title = "Annual Report";
m.Subject = "2024 Annual Employee Report";
m.Company = "Acme Corp";
m.Comments = "Generated automatically";
m.Protection = SheetProtectionMode.Password;
m.Password = "workbook-secret";
})
.BuildAsFile("report.xlsx");
Custom sheet names:
spreadsheet.CreateWorkbook()
.AddSheet<EmployeeExport>("Staff", employees)
.AddSheet<SalesExport>("Revenue", sales)
.Build();
Per-sheet overrides:
spreadsheet.CreateWorkbook()
.AddSheet<EmployeeExport>(employees, overrides =>
{
overrides.SheetName = "Active Staff";
overrides.HiddenColumns = ["InternalId"];
overrides.AutoFilter = true;
})
.Build();
Template Engine
Fill pre-designed .xlsx templates with data. Use {{PropertyName}} placeholders in cells for single-value binding, and named ranges for tabular data.
// From file path (relative to TemplatePath or absolute)
SpreadsheetFile file = spreadsheet.FromTemplate("invoice-template.xlsx")
.Bind(new { CompanyName = "Acme Corp", InvoiceDate = DateTime.Today, InvoiceNumber = "INV-001" })
.BindTable<LineItem>("Items", lineItems)
.WithProtection(SheetProtectionMode.Password, "secret")
.BuildAsFile("invoice-001.xlsx");
// From stream
SpreadsheetFile file = spreadsheet.FromTemplate(templateStream)
.Bind(invoiceData)
.Build();
// From bytes
SpreadsheetFile file = spreadsheet.FromTemplate(templateBytes)
.Bind(invoiceData)
.BuildAsFile("output.xlsx");
Bind replaces {{PropertyName}} placeholders in all cells across all worksheets. BindTable fills a named range with typed data using the same source-generated metadata.
Import
Import Excel files back into typed objects using the same [Sheet] metadata.
// Simple import
List<EmployeeExport> employees = spreadsheet.Import<EmployeeExport>(fileBytes);
List<EmployeeExport> employees = spreadsheet.Import<EmployeeExport>(stream);
// Import with validation (captures errors per row/column)
ImportResult<EmployeeExport> result = spreadsheet.ImportWithValidation<EmployeeExport>(fileBytes);
if (result.HasErrors)
{
foreach (var error in result.Errors)
{
Console.WriteLine($"Row {error.Row}, Column '{error.Column}' ({error.PropertyName}): {error.Message}");
Console.WriteLine($" Raw value: {error.RawValue}");
}
}
// Access valid data
List<EmployeeExport> validData = result.Data;
ImportResult<T>:
| Property | Type | Description |
|---|---|---|
Data |
List<T> |
Successfully parsed rows |
Errors |
List<ImportError> |
Parsing errors |
HasErrors |
bool |
true if any errors occurred |
IsValid |
bool |
true if no errors |
ImportError:
| Property | Type | Description |
|---|---|---|
Row |
int |
1-based row number in the Excel file |
Column |
string |
Column header text |
PropertyName |
string |
Target property name |
Message |
string |
Error description |
RawValue |
object? |
The original cell value that failed conversion |
Sheet Overrides
Override sheet-level and column-level settings at runtime without modifying the class definition.
byte[] bytes = spreadsheet.Export<EmployeeExport>(data, overrides =>
{
overrides.SheetName = "Q4 Employees";
overrides.RightToLeft = true;
overrides.AutoFilter = true;
overrides.FreezeHeader = true;
overrides.HiddenColumns = new HashSet<string> { "InternalId", "Notes" };
overrides.ColumnNameOverrides = new Dictionary<string, string>
{
["Name"] = "Employee Name",
["Department"] = "Dept"
};
overrides.Protection = SheetProtectionMode.Password;
overrides.Password = "override-secret";
overrides.AllowSort = true;
overrides.AllowFilter = false;
});
| Property | Type | Description |
|---|---|---|
SheetName |
string? |
Override sheet tab name |
RightToLeft |
bool? |
Override RTL layout |
AutoFilter |
bool? |
Override auto-filter |
FreezeHeader |
bool? |
Override freeze panes |
HiddenColumns |
HashSet<string>? |
Property names to hide at runtime |
ColumnNameOverrides |
Dictionary<string, string>? |
Map property names to custom headers |
Protection |
SheetProtectionMode? |
Override protection mode |
Password |
string? |
Override protection password |
AllowSort |
bool? |
Override allow sort when protected |
AllowFilter |
bool? |
Override allow filter when protected |
Protection
Three protection modes control sheet and workbook security.
| Mode | Description |
|---|---|
None |
No protection (default) |
Password |
Protected with a user-provided password. Users can unprotect by entering the password |
Sealed |
Protected with a cryptographically random 64-character password (never stored or returned). Effectively permanent read-only |
Sheet-level protection (via attribute):
[Sheet("Payroll", Protection = SheetProtectionMode.Password, Password = "secret123",
AllowSort = true, AllowFilter = true, AllowSelectLockedCells = true)]
public class PayrollExport { ... }
[Sheet("Final Report", Protection = SheetProtectionMode.Sealed)]
public class FinalReportExport { ... }
Workbook-level protection (via metadata):
spreadsheet.CreateWorkbook()
.AddSheet<PayrollExport>(data)
.WithMetadata(m =>
{
m.Protection = SheetProtectionMode.Password;
m.Password = "workbook-password";
m.LockStructure = true; // prevent adding/removing sheets
m.LockWindows = false;
})
.BuildAsFile("payroll.xlsx");
Template protection:
spreadsheet.FromTemplate("template.xlsx")
.Bind(data)
.WithProtection(SheetProtectionMode.Password, "sheet-password")
.WithWorkbookProtection(SheetProtectionMode.Sealed)
.BuildAsFile("output.xlsx");
The source generator reports diagnostics for protection misconfigurations:
SS011(Warning): Protection mode isPasswordbut no password is setSS012(Warning): Password is set but protection mode isNoneSS013(Info): Protection mode isSealedwith a password set (password is ignored forSealed)
DataTable Support
Export System.Data.DataTable objects directly — useful for dynamic/untyped data.
var dt = new DataTable();
dt.Columns.Add("Name", typeof(string));
dt.Columns.Add("Amount", typeof(decimal));
dt.Rows.Add("Product A", 1500.00m);
dt.Rows.Add("Product B", 2300.00m);
SpreadsheetFile file = spreadsheet.CreateWorkbook()
.AddSheet("Products", dt)
.AddSheet("Products Styled", dt, opts =>
{
opts.AutoFilter = true;
opts.FreezeHeader = true;
opts.RightToLeft = false;
opts.HeaderStyle = new CellStyleInfo
{
Bold = true,
BackgroundColor = "#2B5797",
FontColor = "#FFFFFF"
};
opts.ColumnFormats = new Dictionary<string, string>
{
["Amount"] = "#,##0.00"
};
opts.ColumnWidths = new Dictionary<string, double>
{
["Name"] = 30,
["Amount"] = 15
};
opts.HiddenColumns = new HashSet<string> { "InternalId" };
opts.Protection = SheetProtectionMode.Password;
opts.Password = "protect-me";
})
.BuildAsFile("data.xlsx");
DataTableSheetOptions:
| Property | Type | Description |
|---|---|---|
AutoFilter |
bool |
Enable auto-filter |
FreezeHeader |
bool |
Freeze header row |
RightToLeft |
bool |
Right-to-left layout |
HeaderStyle |
CellStyleInfo? |
Header cell styling |
ColumnFormats |
Dictionary<string, string>? |
Column name to Excel format mapping |
ColumnWidths |
Dictionary<string, double>? |
Column name to width mapping |
HiddenColumns |
HashSet<string>? |
Column names to hide |
Protection |
SheetProtectionMode |
Sheet protection mode |
Password |
string? |
Protection password |
Streaming
Write directly to an output stream — useful for large exports or HTTP responses without buffering the entire file in memory.
// Excel streaming
await spreadsheet.ExportToStreamAsync<EmployeeExport>(data, httpResponse.Body, ct);
// CSV streaming
await spreadsheet.ExportCsvToStreamAsync<EmployeeExport>(data, httpResponse.Body, ct);
// Workbook streaming
await spreadsheet.CreateWorkbook()
.AddSheet<EmployeeExport>(employees)
.BuildToStreamAsync(httpResponse.Body, ct);
SpreadsheetFile
SpreadsheetFile wraps the generated output with metadata. It implements IDisposable and IAsyncDisposable.
using SpreadsheetFile file = spreadsheet.ToFile<EmployeeExport>(data, "employees.xlsx");
// Properties
file.FileName; // "employees.xlsx"
file.ContentType; // "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
file.Length; // byte length
// Access data
byte[] bytes = file.GetBytes();
Stream stream = file.GetStream(); // rewindable MemoryStream
await file.CopyToAsync(destination, ct);
file.CopyTo(destination);
// ASP.NET Core integration
return Results.File(file.GetStream(), file.ContentType, file.FileName);
CSV files use content type text/csv.
ISpreadsheet API Reference
The main service interface — inject via DI.
| Method | Returns | Description |
|---|---|---|
Export<T>(data) |
byte[] |
Export to Excel bytes |
Export<T>(data, overrides) |
byte[] |
Export to Excel bytes with overrides |
ExportAsStream<T>(data) |
Stream |
Export to Excel stream |
ExportCsv<T>(data) |
byte[] |
Export to CSV bytes |
ExportCsvAsStream<T>(data) |
Stream |
Export to CSV stream |
ExportCsvAsString<T>(data) |
string |
Export to CSV string |
CreateWorkbook() |
IWorkbookBuilder |
Start building a multi-sheet workbook |
FromTemplate(path) |
ITemplateBuilder |
Open a template from file path |
FromTemplate(stream) |
ITemplateBuilder |
Open a template from stream |
FromTemplate(bytes) |
ITemplateBuilder |
Open a template from bytes |
Import<T>(bytes) |
List<T> |
Import from Excel bytes |
Import<T>(stream) |
List<T> |
Import from Excel stream |
ImportWithValidation<T>(bytes) |
ImportResult<T> |
Import with error tracking |
ImportWithValidation<T>(stream) |
ImportResult<T> |
Import with error tracking |
ToFile<T>(data, fileName?) |
SpreadsheetFile |
Export to Excel file |
ToFile<T>(data, overrides, fileName?) |
SpreadsheetFile |
Export to Excel file with overrides |
ToCsvFile<T>(data, fileName?) |
SpreadsheetFile |
Export to CSV file |
ExportToStreamAsync<T>(data, dest, ct) |
Task |
Stream Excel to destination |
ExportCsvToStreamAsync<T>(data, dest, ct) |
Task |
Stream CSV to destination |
All <T> constraints: where T : class. Import methods additionally require new().
IWorkbookBuilder API Reference
Fluent builder for multi-sheet workbooks.
| Method | Returns | Description |
|---|---|---|
AddSheet<T>(data) |
IWorkbookBuilder |
Add a typed sheet |
AddSheet<T>(data, overrides) |
IWorkbookBuilder |
Add a typed sheet with overrides |
AddSheet<T>(name, data) |
IWorkbookBuilder |
Add a typed sheet with custom name |
AddSheet(name, dataTable) |
IWorkbookBuilder |
Add a DataTable sheet |
AddSheet(name, dataTable, options) |
IWorkbookBuilder |
Add a DataTable sheet with options |
WithMetadata(configure) |
IWorkbookBuilder |
Set workbook metadata |
Build() |
byte[] |
Build workbook as bytes |
BuildAsStream() |
Stream |
Build workbook as stream |
BuildAsFile(fileName?) |
SpreadsheetFile |
Build workbook as file |
BuildToStreamAsync(dest, ct) |
Task |
Stream workbook to destination |
WorkbookMetadata:
| Property | Type | Default | Description |
|---|---|---|---|
Author |
string? |
null |
Document author |
Title |
string? |
null |
Document title |
Subject |
string? |
null |
Document subject |
Company |
string? |
null |
Company name |
Comments |
string? |
null |
Document comments |
Protection |
SheetProtectionMode |
None |
Workbook protection mode |
Password |
string? |
null |
Workbook protection password |
LockStructure |
bool |
true |
Prevent adding/removing/renaming sheets |
LockWindows |
bool |
false |
Prevent window resizing |
ITemplateBuilder API Reference
Fluent builder for template-based spreadsheets.
| Method | Returns | Description |
|---|---|---|
Bind(data) |
ITemplateBuilder |
Replace {{PropertyName}} placeholders with property values |
BindTable<T>(rangeName, data) |
ITemplateBuilder |
Fill a named range with typed data |
WithProtection(mode, password?) |
ITemplateBuilder |
Apply sheet protection |
WithWorkbookProtection(mode, password?) |
ITemplateBuilder |
Apply workbook protection |
Build() |
byte[] |
Build as bytes |
BuildAsStream() |
Stream |
Build as stream |
BuildAsFile(fileName?) |
SpreadsheetFile |
Build as file |
Source Generator Diagnostics
The SpreadsheetGenerator reports compile-time diagnostics with IDs SS001–SS013:
| ID | Severity | Description |
|---|---|---|
SS001 |
Error | Class has no public properties (nothing to export) |
SS002 |
Error | Class has no public parameterless constructor (required for import) |
SS003 |
Error | [ConditionalFormat] When expression could not be parsed |
SS004 |
Warning | [Formula] applied to a non-string property |
SS005 |
Warning | [BooleanFormat] applied to a non-bool property |
SS006 |
Warning | [Dropdown(typeof(T))] where T is not an enum |
SS007 |
Warning | [SummaryRow] references a column name that doesn't exist |
SS008 |
Warning | Duplicate [Column(Order = ...)] values on different properties |
SS009 |
Warning | [MergeWith] references a property name that doesn't exist |
SS010 |
Warning | Both [Column] and [IgnoreColumn] applied to the same property |
SS011 |
Warning | Protection mode is Password but no Password is set |
SS012 |
Warning | Password is set but protection mode is None |
SS013 |
Info | Protection mode is Sealed with a Password set (password is ignored) |
Spreadsheet Configuration
appsettings.json (full options with defaults):
{
"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
}
}
}
C# configuration:
options.Spreadsheet.Enabled = true;
options.Spreadsheet.Options = new SpreadsheetOptions
{
LicenseType = "NonCommercial", // or "Commercial"
LicenseKey = null, // required when LicenseType is "Commercial"
DefaultDateFormat = "yyyy-MM-dd",
DefaultNumberFormat = "#,##0.00",
DefaultFont = new SpreadsheetFontOptions { Name = "Calibri", Size = 11 },
HeaderStyle = new SpreadsheetHeaderStyleOptions
{
Bold = true,
FontSize = 12,
BackgroundColor = "#2B5797",
FontColor = "#FFFFFF",
FreezeHeader = true
},
Csv = new SpreadsheetCsvOptions
{
Delimiter = ",",
Encoding = "UTF-8",
IncludeHeader = true,
DateFormat = "yyyy-MM-dd",
NullValue = ""
},
RightToLeft = false,
MaxExportRows = 100000,
TemplatePath = "templates",
AutoFitColumns = true,
AutoFitMaxWidth = 60,
DefaultColumnWidth = 15
};
Configuration reference:
| Property | Type | Default | Description |
|---|---|---|---|
LicenseType |
string |
"NonCommercial" |
EPPlus license: "NonCommercial" or "Commercial" |
LicenseKey |
string? |
null |
Required when LicenseType is "Commercial" |
DefaultDateFormat |
string |
"yyyy-MM-dd" |
Default format for DateTime/DateOnly columns |
DefaultNumberFormat |
string |
"#,##0.00" |
Default format for numeric columns |
DefaultFont.Name |
string |
"Calibri" |
Default font family |
DefaultFont.Size |
float |
11 |
Default font size in points |
HeaderStyle.Bold |
bool |
true |
Global header bold style |
HeaderStyle.FontSize |
float |
12 |
Global header font size |
HeaderStyle.BackgroundColor |
string |
"#2B5797" |
Global header background color |
HeaderStyle.FontColor |
string |
"#FFFFFF" |
Global header font color |
HeaderStyle.FreezeHeader |
bool |
true |
Freeze header row globally |
Csv.Delimiter |
string |
"," |
CSV field 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 for CSV output |
Csv.NullValue |
string |
"" |
Value to write for null fields in CSV |
RightToLeft |
bool |
false |
Global RTL layout |
MaxExportRows |
int |
100000 |
Maximum rows per export |
TemplatePath |
string |
"templates" |
Base directory for template files |
AutoFitColumns |
bool |
true |
Auto-fit column widths after writing |
AutoFitMaxWidth |
double |
60 |
Maximum auto-fit width |
DefaultColumnWidth |
double |
15 |
Default column width when not auto-fitting or when no [Column(Width = ...)] is set |
Supported data types:
| CLR Type | CellDataType | Notes |
|---|---|---|
string |
String |
|
int |
Integer |
|
long |
Long |
|
decimal |
Decimal |
|
double |
Double |
|
float |
Float |
|
bool |
Boolean |
Use [BooleanFormat] for display customization |
DateTime |
DateTime |
|
DateOnly |
DateOnly |
|
TimeOnly |
TimeOnly |
|
Guid |
Guid |
|
enum |
Enum |
Exported as string name, imported via Enum.TryParse |
Nullable variants (int?, DateTime?, etc.) are automatically unwrapped.
Cloud Storage
MinIO (S3-Compatible)
appsettings.json:
{
"Minio": {
"Enabled": true,
"Options": {
"Endpoint": "minio.example.com",
"Region": "us-east-1",
"Secure": true,
"AccessKey": "YOUR_ACCESS_KEY",
"SecretKey": "YOUR_SECRET_KEY"
}
}
}
C# configuration:
options.Minio.Enabled = true;
options.Minio.Options = new MinioOptions
{
Endpoint = "minio.example.com",
Region = "us-east-1",
Secure = true,
AccessKey = "...",
SecretKey = "..."
};
Inject IMinioClient to interact with S3-compatible storage.
AWS S3
appsettings.json:
{
"Aws": {
"S3": {
"Enabled": true,
"Options": {
"Region": "us-west-2",
"AccessKey": "YOUR_ACCESS_KEY",
"SecretKey": "YOUR_SECRET_KEY"
}
}
}
}
C# configuration:
options.Aws.S3.Enabled = true;
options.Aws.S3.Options = new AwsS3Options
{
Region = "us-west-2",
AccessKey = "...",
SecretKey = "..."
};
Inject IAmazonS3 to interact with AWS S3.
Azure Blob Storage
appsettings.json:
{
"Azure": {
"BlobStorage": {
"Enabled": true,
"Options": {
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net"
}
}
}
}
C# configuration:
options.Azure.BlobStorage.Enabled = true;
options.Azure.BlobStorage.Options = new AzureBlobStorageOptions
{
ConnectionString = "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net"
};
Inject BlobServiceClient to interact with Azure Blob Storage.
Unified File Storage (IFileStorage)
A single interface for uploading, downloading, deleting, and checking file existence across Local, AWS S3, and Azure Blob Storage — selected per call via StorageProvider.
public enum StorageProvider { Local, S3, AzureBlob }
appsettings.json:
{
"FileStorage": {
"Enabled": true,
"Options": {
"Provider": "S3",
"Container": "my-bucket",
"MaxFileSizeBytes": 52428800,
"AllowedExtensions": [".jpg", ".png", ".pdf", ".docx"]
}
}
}
C# configuration:
options.FileStorage.Enabled = true;
options.FileStorage.Options = new FileStorageOptions
{
Provider = StorageProvider.S3,
Container = "my-bucket",
MaxFileSizeBytes = 50 * 1024 * 1024,
AllowedExtensions = [".jpg", ".png", ".pdf", ".docx"]
};
Using the configured default provider:
// Upload — uses the configured provider & container
await fileStorage.UploadAsync("images/photo.jpg", stream, "image/jpeg", ct);
// Download
var stream = await fileStorage.DownloadAsync("docs/report.pdf", ct);
// Delete
await fileStorage.DeleteAsync("temp/file.txt", ct);
// Check existence
var exists = await fileStorage.ExistsAsync("images/photo.jpg", ct);
Override per call:
// Upload to S3
await fileStorage.UploadAsync(StorageProvider.S3, "my-bucket", "images/photo.jpg", stream, "image/jpeg", ct);
// Download from Azure Blob
var stream = await fileStorage.DownloadAsync(StorageProvider.AzureBlob, "my-container", "docs/report.pdf", ct);
// Delete from local storage (container = directory under ContentRootPath)
await fileStorage.DeleteAsync(StorageProvider.Local, "uploads", "temp/file.txt", ct);
// Check existence
var exists = await fileStorage.ExistsAsync(StorageProvider.S3, "my-bucket", "images/photo.jpg", ct);
| Parameter | S3 | Azure Blob | Local |
|---|---|---|---|
container |
Bucket name | Container name | Directory under ContentRootPath |
path |
Object key | Blob name | Relative file path |
No configuration needed — IFileStorage is always registered. It uses whatever cloud clients are available via DI (IAmazonS3, BlobServiceClient). If you only configure AWS S3, use StorageProvider.S3. If you only configure Azure, use StorageProvider.AzureBlob. StorageProvider.Local always works — files are stored under ContentRootPath/{container}/{path} with path traversal protection.
If a provider's SDK client is not registered (e.g., calling StorageProvider.S3 without enabling AWS S3), an InvalidOperationException is thrown with a clear message.
AWS Secrets Manager
appsettings.json:
{
"Aws": {
"SecretsManager": {
"Enabled": true,
"Options": {
"Region": "us-west-2",
"SecretArns": ["arn:aws:secretsmanager:us-west-2:123456789:secret:my-secret"],
"AccessKey": "YOUR_ACCESS_KEY",
"SecretKey": "YOUR_SECRET_KEY"
}
}
}
}
C# configuration:
options.Aws.SecretsManager.Enabled = true;
options.Aws.SecretsManager.Options = new AwsSecretsManagerOptions
{
Region = "us-west-2",
AccessKey = "...",
SecretKey = "...",
SecretArns = ["arn:aws:secretsmanager:us-west-2:123456789:secret:my-secret"]
};
Secrets are automatically loaded into IConfiguration.
Image Processing (IImageService)
A built-in service for resizing, compressing images to WebP, and generating BlurHash placeholder strings. Powered by SixLabors.ImageSharp and Blurhash.ImageSharp.
Inject IImageService — always registered, no configuration needed.
Process (Resize + Compress)
await using var result = await imageService.ProcessAsync(file.OpenReadStream(), new ImageProcessingOptions
{
MaxWidth = 1024,
MaxHeight = 1024,
Quality = 70,
NearLossless = true,
NearLosslessQuality = 50,
SkipMetadata = true,
}, ct);
// result.Stream — WebP MemoryStream (position reset to 0)
// result.ContentType — "image/webp"
// result.Width — final width after resize
// result.Height — final height after resize
// result.Length — byte length of the WebP stream
// Upload using IFileStorage
await fileStorage.UploadAsync(StorageProvider.S3, "my-bucket", "images/photo.webp", result.Stream, result.ContentType, ct);
Default options (when called with ProcessAsync(stream)):
| Option | Default | Description |
|---|---|---|
MaxWidth |
512 |
Max width — image is resized if it exceeds this |
MaxHeight |
512 |
Max height — image is resized if it exceeds this |
Quality |
70 |
WebP quality (0-100) |
NearLossless |
true |
Enable near-lossless WebP encoding |
NearLosslessQuality |
50 |
Near-lossless quality (0-100) |
SkipMetadata |
true |
Strip EXIF and other metadata |
Images are resized using ResizeMode.Max — the largest dimension fits within the bounds while preserving aspect ratio. Images smaller than the max dimensions are not upscaled.
BlurHash
Generate a BlurHash string independently from any image stream:
var blurHash = await imageService.BlurHashAsync(file.OpenReadStream(), componentsX: 4, componentsY: 3, ct);
// Returns: "LGF5]+Yk^6#M@-5c,1BCSNGJz[yW" (example)
| Parameter | Default | Description |
|---|---|---|
componentsX |
4 |
Horizontal detail (1-9) |
componentsY |
3 |
Vertical detail (1-9) |
Higher component values produce more detailed (and longer) hash strings.
Combined Usage
// Process image + generate BlurHash from the same file
await using var result = await imageService.ProcessAsync(file.OpenReadStream(), ct: ct);
file.OpenReadStream().Position = 0; // reset stream position
var blurHash = await imageService.BlurHashAsync(file.OpenReadStream(), ct: ct);
await fileStorage.UploadAsync(StorageProvider.S3, bucket, path, result.Stream, result.ContentType, ct);
// Store blurHash, result.Width, result.Height, result.Length in your database
Firebase
Cloud Messaging
appsettings.json:
{
"Firebase": {
"Messaging": {
"Enabled": true,
"Options": {
"Credential": "BASE_64_ENCODED_SERVICE_ACCOUNT_JSON"
}
}
}
}
C# configuration:
options.Firebase.Messaging.Enabled = true;
options.Firebase.Messaging.Options = new FirebaseMessagingOptions
{
Credential = Convert.ToBase64String(Encoding.UTF8.GetBytes(serviceAccountJson))
};
Inject FirebaseMessaging to send push notifications.
App Check
appsettings.json:
{
"Firebase": {
"AppCheck": {
"Enabled": true,
"Options": {
"ProjectId": "my-project",
"ProjectNumber": "123456789"
}
}
}
}
C# configuration:
options.Firebase.AppCheck.Enabled = true;
options.Firebase.AppCheck.Options = new FirebaseAppCheckOptions
{
ProjectId = "my-project",
ProjectNumber = "123456789"
};
Inject IFirebaseAppCheck and call VerifyAppCheckTokenAsync().
Infrastructure
| Feature | Configuration | Default |
|---|---|---|
| CORS | Cors.AllowedOrigins |
Disabled |
| Health Checks | HealthChecks.Enabled |
/healthz |
| Static Files | StaticFiles.Enabled |
wwwroot |
| Response Compression | ResponseCompression.Enabled |
Enabled, Brotli + Gzip |
| Forwarded Headers | ForwardedHeaders.Enabled |
Disabled |
| Rate Limiting | RateLimiting.Enabled |
Disabled |
| Request Limits | RequestLimits |
50 MB body, 128 MB multipart |
| Logging | Serilog via appsettings.json |
Console sink, sensitive data masking |
| Distributed Cache | DistributedCache.Enabled |
InMemory or Redis |
| File Storage | FileStorage.Enabled |
Local, S3, Azure Blob |
| Image Processing | Always registered (IImageService) |
Resize, WebP compress, BlurHash |
| Spreadsheet | Spreadsheet.Enabled |
Excel/CSV export, import, templates |
CORS
appsettings.json:
{
"Cors": {
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["GET", "POST", "PUT", "DELETE", "PATCH"],
"AllowedHeaders": ["Content-Type", "Authorization"],
"AllowCredentials": true,
"MaxAgeSeconds": 600
}
}
C# configuration:
options.Cors.AllowedOrigins = ["https://example.com"];
options.Cors.AllowedMethods = ["GET", "POST", "PUT", "DELETE", "PATCH"]; // empty = any method
options.Cors.AllowedHeaders = ["Content-Type", "Authorization"]; // empty = any header
options.Cors.AllowCredentials = true; // default: true
options.Cors.MaxAgeSeconds = 600; // preflight cache, default: 600
Response Compression
Enabled by default with Brotli and Gzip providers.
appsettings.json:
{
"ResponseCompression": {
"Enabled": true,
"Options": {
"EnableForHttps": true,
"Providers": ["Brotli", "Gzip"],
"MimeTypes": ["text/plain", "application/json", "text/html"]
}
}
}
C# configuration:
options.ResponseCompression.Enabled = true; // default
options.ResponseCompression.Options = new ResponseCompressionOptions
{
EnableForHttps = true,
Providers = ["Brotli", "Gzip"],
MimeTypes = ["text/plain", "application/json"] // empty = framework defaults
};
Forwarded Headers
Disabled by default. Enable when running behind a reverse proxy (nginx, load balancer).
appsettings.json:
{
"ForwardedHeaders": {
"Enabled": true,
"Options": {
"ForwardedHeaders": ["XForwardedFor", "XForwardedProto"],
"KnownProxies": [],
"KnownNetworks": []
}
}
}
C# configuration:
options.ForwardedHeaders.Enabled = true;
options.ForwardedHeaders.Options = new ForwardedHeadersOptions
{
ForwardedHeaders = ["XForwardedFor", "XForwardedProto"],
KnownProxies = ["10.0.0.1"],
KnownNetworks = ["10.0.0.0/8"] // CIDR notation
};
Valid ForwardedHeaders values: XForwardedFor, XForwardedHost, XForwardedProto, All.
Request Limits
appsettings.json:
{
"RequestLimits": {
"MaxRequestBodySizeBytes": 52428800,
"MaxMultipartBodyLengthBytes": 134217728
}
}
C# configuration:
options.RequestLimits = new RequestLimitsOptions
{
MaxRequestBodySizeBytes = 50 * 1024 * 1024, // default: 50 MB
MaxMultipartBodyLengthBytes = 128 * 1024 * 1024 // default: 128 MB
};
MaxMultipartBodyLengthBytes must be >= MaxRequestBodySizeBytes.
Rate Limiting
Disabled by default. Supports FixedWindow, SlidingWindow, TokenBucket, and Concurrency algorithms.
appsettings.json:
{
"RateLimiting": {
"Enabled": true,
"Options": {
"GlobalPolicy": "Default",
"RejectionStatusCode": 429,
"Policies": {
"Default": {
"Type": "FixedWindow",
"PermitLimit": 100,
"WindowSeconds": 60,
"QueueLimit": 0
},
"Auth": {
"Type": "SlidingWindow",
"PermitLimit": 10,
"WindowSeconds": 60,
"SegmentsPerWindow": 6
}
}
}
}
}
C# configuration:
options.RateLimiting.Enabled = true;
options.RateLimiting.Options = new RateLimitingOptions
{
GlobalPolicy = "Default",
RejectionStatusCode = 429,
Policies = new Dictionary<string, RateLimitingPolicyOptions>
{
["Default"] = new() { Type = RateLimitingPolicyType.FixedWindow, PermitLimit = 100, WindowSeconds = 60 },
["Auth"] = new() { Type = RateLimitingPolicyType.SlidingWindow, PermitLimit = 10, WindowSeconds = 60, SegmentsPerWindow = 6 },
["Upload"] = new() { Type = RateLimitingPolicyType.TokenBucket, TokenLimit = 10, ReplenishmentPeriodSeconds = 10, TokensPerPeriod = 2 }
}
};
Partitioning: Authenticated users are rate-limited per user ID, anonymous users per IP address. Implement IRateLimitPartitioner for custom logic.
Per-endpoint policies:
public override void Configure()
{
Post("upload");
Options(x => x.RequireRateLimiting("Upload"));
}
Policy types:
| Type | Key Properties | Description |
|---|---|---|
FixedWindow |
PermitLimit, WindowSeconds |
Fixed time window, resets at boundary |
SlidingWindow |
PermitLimit, WindowSeconds, SegmentsPerWindow |
Sliding segments for smoother limiting |
TokenBucket |
TokenLimit, TokensPerPeriod, ReplenishmentPeriodSeconds |
Steady-rate with burst capacity |
Concurrency |
PermitLimit |
Limits concurrent requests |
All types support QueueLimit (default 0 = reject immediately).
Distributed Cache
appsettings.json:
{
"DistributedCache": {
"Enabled": true,
"Options": {
"Provider": "Redis",
"Redis": {
"ConnectionString": "localhost:6379",
"InstanceName": "DC_"
}
}
}
}
C# configuration:
options.DistributedCache.Enabled = true;
options.DistributedCache.Options = new DistributedCacheOptions
{
Provider = DistributedCacheProviders.Redis, // or Memory
Redis = new DistributedCacheRedisOptions
{
ConnectionString = "localhost:6379",
InstanceName = "DC_"
}
};
Inject IDistributedCache to use the cache. When Provider is Memory, an in-memory cache is registered (useful for development). When Provider is Redis, a StackExchange Redis-backed cache is registered.
Health Checks
appsettings.json:
{
"HealthChecks": {
"Enabled": true,
"Options": {
"Path": "/healthz"
}
}
}
C# configuration:
options.HealthChecks.Enabled = true;
options.HealthChecks.Options = new HealthChecksOptions
{
Path = "/healthz",
HealthChecksBuilder = hc => hc.AddDbContextCheck<AppDbContext>()
};
Static Files
appsettings.json:
{
"StaticFiles": {
"Enabled": true,
"Options": {
"RootPath": "wwwroot",
"RequestPath": "",
"ContentTypeMappings": {
".custom": "application/x-custom-type"
}
}
}
}
C# configuration:
options.StaticFiles.Enabled = true;
options.StaticFiles.Options = new StaticFilesOptions { RootPath = "wwwroot" };
Logging
Serilog is pre-configured via appsettings.json with console sink and sensitive data masking.
{
"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" }
]
}
}
Substratum.Generator
Eight Roslyn incremental source generators that eliminate boilerplate at compile time.
1. SubstratumApp Generator
Generates a [ModuleInitializer] that wires up the framework. Scans for:
DbContextimplementationIPermissionRegistryimplementationISessionValidator,IPermissionHydrator,IBasicAuthValidator,IAccessKeyValidator,IRefreshTokenStore,IAppResolverimplementations
Output: SubstratumAppInitializer.g.cs
2. Discovered Types Generator
Scans for all concrete classes implementing FastEndpoints interfaces (IEndpoint, IEventHandler, ICommandHandler, ISummary, IValidator) and collects them into a type list. Respects [DontRegister].
Output: DiscoveredTypes.g.cs
3. Service Registration Generator
Scans for [RegisterService<TInterface>(ServiceLifetime)] attributes and generates DI extension methods.
[RegisterService<IOrderService>(ServiceLifetime.Scoped)]
public class OrderService : IOrderService { }
Output: ServiceRegistrations.g.cs with RegisterServicesFromMyApp() extension method.
4. Reflection Generator
Builds a FastEndpoints reflection cache by analyzing endpoint request/response DTOs — object factories, property setters, and value parsers. Eliminates runtime reflection.
Output: ReflectionData.g.cs
5. Permissions Generator
Generates the Definitions(), Parse(), and TryParse() methods for IPermissionRegistry implementations. Auto-computes permission codes, names, display names, and groups from field names.
Output: {ClassName}.Permissions.g.cs
6. Endpoint Summary Generator
Scans BaseEndpoint<TRequest, TResponse> subclasses and generates OpenAPI summary classes. Auto-detects:
- Error responses from
Failure()calls - 400 if a
Validator<TRequest>exists - 401 if the endpoint is not
AllowAnonymous() - 403 if permissions are declared
Output: {EndpointName}Summary.g.cs
7. Document Group Generator
Generates Definitions() and a [ModuleInitializer] for IDocGroupRegistry implementations.
Output: {ClassName}.DocGroups.g.cs
8. Spreadsheet Generator
Scans for classes decorated with [Sheet] and generates:
- Per-class metadata:
{ClassName}SheetMetadata : ISheetMetadata<T>with direct property-accessWriteRow/ReadRowmethods (zero reflection) - Registry:
[ModuleInitializer]that registers all metadata intoSheetMetadataStore
The generated ISheetMetadata<T> implementation includes:
- Sheet-level properties (name, RTL, auto-filter, freeze, tab color, protection, summary row)
- Column definitions with all styles, formats, conditions, and validation
WriteRow(T item, Action<int, object?> setCellValue)— direct property readsReadRow(Func<int, object?> getCellValue)— type-safe conversion perCellDataType
Output: {ClassName}SheetMetadata.g.cs, SheetMetadataRegistrations.g.cs
Substratum.Tools (CLI)
Install globally:
dotnet tool install -g Substratum.Tools
Commands
dotnet sub new webapp
Scaffold a complete web application:
dotnet sub new webapp --name MyApp
Generates a full project with:
- Pre-configured
Program.cswithSubstratumApp.RunAsync AppDbContextwith User, Role, UserSession entitiesAppPermissionsregistrySessionValidatorandPermissionHydrator- Localization resources (EN, AR)
- Initial EF migration
AGENTS.mdwith AI scaffolding guidelines
dotnet sub new endpoint
Scaffold a new API endpoint:
dotnet sub new endpoint \
--group Orders \
--name CreateOrder \
--route /orders \
--method Post \
--permission OrdersCreate \
--response-type SingleResult
Generates under Features/Orders/CreateOrder/:
CreateOrderEndpoint.cs— endpoint handlerCreateOrderRequest.cs— request DTOCreateOrderResponse.cs— response DTOCreateOrderRequestValidator.cs— FluentValidation validatorCreateOrderSummary.cs— OpenAPI summaryCreateOrderSerializerContext.cs— JSON source-gen context
Also inserts the permission definition into AppPermissions.cs automatically.
For paginated endpoints, use --response-type PaginatedResult — the request will include PageNumber and PageSize properties.
All options are interactive — omit any flag and the CLI will prompt you.
dotnet sub new entity
Scaffold a domain entity:
dotnet sub new entity --name Order
Generates:
Domain/Entities/Order.cs— entity class inheritingBaseEntity<Guid>Data/Configurations/OrderConfiguration.cs— EF Core configuration
Also inserts public DbSet<Order> Orders => Set<Order>(); into AppDbContext.cs.
dotnet sub migrations add
Add an EF Core migration:
dotnet sub migrations add AddOrderTable
dotnet sub database update
Apply pending migrations:
dotnet sub database update
dotnet sub database sql
Generate an idempotent SQL script:
dotnet sub database sql -o ./deploy.sql
Full Configuration Reference
Complete appsettings.json with all features:
{
"ServerEnvironment": "Development",
"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"],
"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_SUPER_SECRET_KEY_MUST_BE_LONG_ENOUGH_AND_CHANGED_FOR_PRODUCTION",
"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": ".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"
}
},
"AccessKeyAuthentication": {
"Enabled": false,
"Options": {
"Realm": "MyApp",
"KeyName": "X-API-KEY"
}
}
},
"EntityFramework": {
"Default": {
"Provider": "Npgsql",
"ConnectionString": "Host=localhost;Database=myapp;Username=postgres;Password=password",
"CommandTimeoutSeconds": 30,
"EnableSeeding": true,
"Logging": {
"EnableDetailedErrors": true,
"EnableSensitiveDataLogging": true
},
"RetryPolicy": {
"Enabled": true,
"Options": {
"MaxRetryCount": 3,
"MaxRetryDelaySeconds": 5
}
},
"SecondLevelCache": {
"Enabled": true,
"Options": {
"KeyPrefix": "EF_",
"Provider": "Memory"
}
}
}
},
"ErrorHandling": {
"IncludeExceptionDetails": true
},
"Localization": {
"DefaultCulture": "en"
},
"OpenApi": {
"Enabled": true,
"Options": {
"Servers": [
{ "Url": "https://localhost:5000", "Description": "Local Development" }
]
}
},
"StaticFiles": {
"Enabled": false,
"Options": {
"RootPath": "wwwroot",
"RequestPath": ""
}
},
"HealthChecks": {
"Enabled": true,
"Options": {
"Path": "/healthz"
}
},
"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": {
"Region": "us-east-1",
"AccessKey": "YOUR_ACCESS_KEY",
"SecretKey": "YOUR_SECRET_KEY"
}
},
"SecretsManager": {
"Enabled": false,
"Options": {
"Region": "us-east-1",
"SecretArns": ["YOUR_SECRET"],
"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"
}
}
},
"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
}
}
}
Interfaces to Implement
| Interface | Purpose | When Required |
|---|---|---|
IPermissionRegistry |
Define all permissions | Always (source-generated) |
IDocGroupRegistry |
Define API document groups | When using document groups (source-generated) |
ISessionValidator |
Validate active sessions | JWT or Cookie auth |
IPermissionHydrator |
Load user permissions into claims | When using permissions |
IBasicAuthValidator |
Validate username/password | Basic auth enabled |
IAccessKeyValidator |
Validate access keys | Access key auth or protected document groups |
IRefreshTokenStore |
Store and validate refresh tokens | JWT with refresh token rotation |
IAppResolver |
Validate app IDs for multi-app isolation | App-scoped authentication |
IRateLimitPartitioner |
Custom partition key for rate limiting | Custom rate limit partitioning |
IDbContextInitializer<T> |
Seed data on startup | Database seeding |
All implementations are auto-discovered by the source generators — no manual registration needed.
License
See LICENSE for details.
Learn more about Target Frameworks and .NET Standard.
-
.NETStandard 2.0
- FastEndpoints.Attributes (>= 7.2.0)
- Scriban (>= 6.5.2)
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.154 | 37 | 2/23/2026 |
| 1.0.0-beta.153 | 34 | 2/23/2026 |
| 1.0.0-beta.152 | 33 | 2/23/2026 |
| 1.0.0-beta.151 | 39 | 2/23/2026 |
| 1.0.0-beta.150 | 37 | 2/22/2026 |
| 1.0.0-beta.149 | 37 | 2/22/2026 |
| 1.0.0-beta.148 | 39 | 2/21/2026 |
| 1.0.0-beta.147 | 36 | 2/21/2026 |
| 1.0.0-beta.146 | 41 | 2/18/2026 |
| 1.0.0-beta.145 | 36 | 2/18/2026 |
| 1.0.0-beta.144 | 35 | 2/18/2026 |
| 1.0.0-beta.143 | 39 | 2/18/2026 |
| 1.0.0-beta.141 | 38 | 2/17/2026 |
| 1.0.0-beta.140 | 39 | 2/17/2026 |
| 1.0.0-beta.130 | 41 | 2/17/2026 |
| 1.0.0-beta.122 | 40 | 2/16/2026 |
| 1.0.0-beta.110 | 41 | 2/15/2026 |
| 1.0.0-beta.107 | 41 | 2/15/2026 |
| 1.0.0-beta.104 | 42 | 2/14/2026 |
| 1.0.0-beta.96 | 43 | 2/14/2026 |