# fullstackhero — .NET 10 Starter Kit (full documentation)
> Free, MIT-licensed .NET 10 starter kit with a React admin + dashboard. Modular monolith, vertical slice, multitenant, with ten ready-to-ship modules.
Generated from the canonical docs site at https://fullstackhero.net.
Source repository: https://github.com/fullstackhero/dotnet-starter-kit
License: MIT
Pages: 92
# Overview
> How fullstackhero is wired for AI coding tools — AGENTS.md, the .agents folder, rules, skills, and workflows — and how to build with Claude Code.
Source: https://fullstackhero.net/docs/ai-development/
fullstackhero ships **agent-ready**. The conventions, footguns, and step-by-step recipes that normally
live in a senior engineer's head are written down in a form AI coding tools read on demand — so an agent
scaffolds a feature, adds a module, or writes a migration the *same* way you would, without breaking the
architecture.
Two pieces make this work:
- **AGENTS.md** — the single, tool-neutral guide at the repo root. CLAUDE.md and GEMINI.md are thin bridges that import it, so every tool reads the same source of truth.
- **.agents/** — a folder of **rules** (conventions, read on demand), **skills** (task recipes), and **workflows** (review/orchestration playbooks).
Everything here is plain Markdown keyed off the open AGENTS.md convention.
It works with Claude Code, Gemini CLI, Cursor, Codex, and anything else that reads `AGENTS.md` — no
vendor lock-in.
---
# AGENTS.md & the .agents Folder
> The canonical AGENTS.md guide, the CLAUDE.md/GEMINI.md bridges, the .agents folder structure, and the on-demand rule files that encode every convention.
Source: https://fullstackhero.net/docs/ai-development/agents-md/
## AGENTS.md — the canonical guide
**AGENTS.md** at the repo root is the single source of truth for AI tools. It's deliberately lean: a
repo map, the tech stack, full-stack build/run commands, a short list of "golden rules" that must never be
broken, and an index pointing into `.agents/rules/`. Conventions are edited *there* — not in the bridges.
**CLAUDE.md** (Claude Code) and **GEMINI.md** (Gemini CLI) are one-line bridges that import it:
```md title="CLAUDE.md"
# Claude Code
The canonical project guide is **`AGENTS.md`** (tool-neutral). It is imported below; edit conventions there, not here.
@AGENTS.md
```
So every tool — and every contributor — reads the same guide, and there's exactly one place to update.
Only the root guide loads into the agent's context on every session. Detailed conventions live in
`.agents/rules/` and are pulled in **on demand**, when the agent is actually working in that area. That
keeps each session cheap while still having deep guidance available the moment it's needed.
## The `.agents/` folder
```
AGENTS.md ← canonical guide (CLAUDE.md / GEMINI.md import it)
.agents/
├── rules/ ← conventions & footguns (read on demand)
│ ├── architecture.md module boundaries, registration, middleware order
│ ├── api-conventions.md endpoints, CQRS, validation, exceptions, permissions
│ ├── database.md EF Core, migrations, tenant isolation, query filters
│ ├── eventing.md domain vs integration events, Outbox/Inbox
│ ├── caching.md jobs.md resilience.md storage.md
│ ├── security.md CORS, security headers, rate limiting, idempotency, quotas
│ ├── realtime.md logging.md testing.md integration-testing.md
│ ├── buildingblocks-protection.md read before touching src/BuildingBlocks (it's protected)
│ ├── modules/ one file per module (identity, multitenancy, chat, files,
│ │ webhooks, auditing, billing, catalog, tickets, notifications)
│ └── frontend/ shared.md · admin.md · dashboard.md (the two React apps)
├── skills/ ← task recipes (see Skills & Workflows)
│ └── {skill}/SKILL.md
└── workflows/ ← review / orchestration playbooks
└── {workflow}.md
```
## Rules — conventions, read on demand
A **rule** is knowledge the agent reads before working in an area — the conventions and the *footguns*
that are easy to get wrong. Rules don't restate what the code or tests already enforce; they point at the
enforcement and capture the non-obvious. The root `AGENTS.md` carries the index, e.g. *"before EF or
migration work, read `database.md`."*
| Working on… | Rule |
|---|---|
| Module structure, boundaries, registration, middleware order | `architecture.md` |
| Endpoints, CQRS, validation, exceptions, permissions | `api-conventions.md` |
| EF Core, entities, migrations, tenant isolation | `database.md` |
| Cross-module events, Outbox/Inbox, idempotent handlers | `eventing.md` |
| Caching · jobs · HTTP resilience · file storage | `caching.md` · `jobs.md` · `resilience.md` · `storage.md` |
| CORS, security headers, rate limiting, idempotency, quotas | `security.md` |
| SignalR / SSE · logging & OpenTelemetry | `realtime.md` · `logging.md` |
| Unit tests · integration tests (Testcontainers) | `testing.md` · `integration-testing.md` |
| Modifying `src/BuildingBlocks` (protected — read first) | `buildingblocks-protection.md` |
| A specific module's quirks | `modules/{module}.md` |
| Any React work · the operator app · the tenant app | `frontend/shared.md` · `frontend/admin.md` · `frontend/dashboard.md` |
A few examples of the kind of footgun these capture: registering a module touches **four** places (Mediator
assemblies + module array, in *both* the API and DbMigrator hosts); tenant isolation is default-on via
`BaseDbContext`; new entity ids use `Guid.CreateVersion7()`; log messages must be structured (no string
interpolation). Each is one read away when the agent needs it.
Next: the **[Skills & Workflows](/docs/ai-development/skills-and-workflows/)** that turn these conventions
into repeatable actions.
---
# Developing with Claude Code
> A practical guide to building on fullstackhero with Claude Code — how it loads AGENTS.md, invokes skills, runs review workflows, and the build/test loop to follow.
Source: https://fullstackhero.net/docs/ai-development/claude-code/
[Claude Code](https://docs.claude.com/en/docs/claude-code/overview) is Anthropic's agentic CLI. Because
this repo is wired with `AGENTS.md` and `.agents/`, Claude works *on-convention* out of the box — it
scaffolds slices, wires modules, and writes migrations the way the kit expects.
## 1. Install & open
```bash
npm install -g @anthropic-ai/claude-code # see the official docs for native installers
cd dotnet-starter-kit
claude
```
On startup, Claude Code reads `CLAUDE.md`, which imports `AGENTS.md` — so the repo map, golden rules, and
the rules index are in context immediately. The detailed rule files load **on demand** as Claude works.
Don't paste conventions into the chat — they're already in `AGENTS.md` and `.agents/rules/`. If something
is wrong or missing, fix the rule file; every session (and every contributor) picks it up.
## 2. Build features by describing them
You don't invoke skills by name — describe the task and the matching skill activates:
| You say… | Skill that runs |
|---|---|
| "Add a `CreateInvoice` feature to Billing" | `add-feature` |
| "Scaffold a `Reviews` module" | `add-module` (incl. the four-place registration) |
| "Add a brands list+create page to the dashboard" | `add-react-page` |
| "Build assignee filtering end-to-end for Tickets" | `add-full-slice` |
| "Create a migration for the new column" | `create-migration` |
| "Publish an `InvoicePaid` event that Notifications reacts to" | `add-integration-event` |
Each skill ends by building/testing, so you get working, on-convention code rather than a sketch.
## 3. Review before you commit
Two read-only workflows act as guardrails — ask for them by intent:
```text
"Review my changes" → code-reviewer (diff vs. conventions, structured report)
"Check architecture" → architecture-guard (boundaries, BuildingBlocks, arch tests)
```
If the Roslyn navigator MCP is configured (the `dotnet-claude-kit` plugin), the reviewer also runs
`detect_antipatterns` and `get_diagnostics` for machine-found issues.
## 4. The build/test loop
```bash
dotnet build src/FSH.Starter.slnx # 0 warnings — the repo runs TreatWarningsAsErrors
dotnet test src/FSH.Starter.slnx # unit + architecture + integration
```
`Integration.Tests` spins up real Postgres, Valkey, and MinIO via Testcontainers. If Docker isn't running
you'll see `DockerUnavailableException` — that's environmental, not a code regression. Run the unit
projects (e.g. `dotnet test src/Tests/Catalog.Tests`) to validate logic without Docker.
To see it all running, the Aspire AppHost brings up the whole stack — Postgres, Valkey, MinIO, the migrator,
the API, and both React apps — with one command:
```bash
dotnet run --project src/Host/FSH.Starter.AppHost
```
## 5. Golden rules worth remembering
Claude follows these from `AGENTS.md`; knowing them helps you review its work:
- **Module boundaries** — modules talk only through `*.Contracts`. Enforced by `Architecture.Tests`.
- **Registering a module touches four places** — Mediator assemblies + module array, in *both* the API and DbMigrator hosts. A missing Mediator marker means handlers silently don't run.
- **Tenant isolation is default-on** via `BaseDbContext`; opt out only via `IGlobalEntity`.
- **Every command + paginated query needs a validator**; handlers are `public sealed`, return `ValueTask`.
- **Structured logging only** — no string interpolation in log messages.
- **Don't modify `src/BuildingBlocks`** without intent — it's shared by every module.
## 6. Keep the guidance sharp
The `.agents/` set is living documentation. When you establish a new convention or hit a new footgun, add
or update a rule (or a skill), commit it, and the whole team's agents improve. The
[AGENTS.md & .agents/](/docs/ai-development/agents-md/) page explains the structure;
[Skills & Workflows](/docs/ai-development/skills-and-workflows/) shows how to extend the set.
---
# Skills & Workflows
> The catalog of .agents skills (task recipes like add-feature, add-module, create-migration) and workflows (review and orchestration playbooks) that ship…
Source: https://fullstackhero.net/docs/ai-development/skills-and-workflows/
Rules are knowledge; **skills** and **workflows** are action.
- A **skill** (`.agents/skills/{name}/SKILL.md`) is a *verb* — a repeatable, footgun-aware recipe for one task that ends in a green build/test. The agent reaches for it when your request matches its description.
- A **workflow** (`.agents/workflows/{name}.md`) is *orchestration* — it sequences skills and adds review/verification. Workflows delegate the code recipe to the skills rather than duplicating them.
## Skills
### Scaffolders — go from idea to working code
| Skill | What it does |
|---|---|
| `add-feature` | A backend vertical slice: Command/Query in Contracts → handler (injects the module `DbContext`) → validator → endpoint → wired in the module. |
| `add-entity` | A domain aggregate + EF configuration + migration, with the soft-delete / tenant / value-generation conventions baked in. |
| `add-module` | A whole new bounded context — both projects, `IModule`, `BaseDbContext`, permissions, and registration in **all four** sites. |
| `add-react-page` | A frontend slice (admin or dashboard): API module, page, lazy route, permission gate, Playwright test. |
| `add-full-slice` | End-to-end: the backend slice **and** the React page wired to it — the kit's accelerator. |
### Ops — footgun-heavy operations
| Skill | What it does |
|---|---|
| `create-migration` | The exact EF Core migration command (`--project`/`--startup-project`/`--context`/`--output-dir`), build-first to protect the snapshot, review the SQL, apply via the DbMigrator. |
| `add-integration-event` | Publish a cross-module event through the **Outbox** and handle it idempotently — including background tenant-context restoration. |
| `add-permission` | A new permission end-to-end: server constant + endpoint gate, and (admin app) the mirrored catalog entry + route guard. |
### Reference — guardrails
| Skill | What it covers |
|---|---|
| `query-patterns` | Read queries the FSH way — paginated `PagedResponse` via DbContext LINQ; when to use a Specification. |
| `testing-guide` | Test conventions — xUnit + Shouldly + NSubstitute + AutoFixture, naming, and the integration-test gotchas. |
| `mediator-reference` | This kit uses the **Mediator** source generator, *not* MediatR — the interface map and the registration sites. |
## Workflows
| Workflow | Role |
|---|---|
| `feature-scaffolder` | Orchestrates feature delivery — sequences `add-entity` → `add-feature` → `add-react-page`/`add-full-slice` → tests → verify. |
| `module-creator` | Orchestrates a full module bring-up and verifies it actually *loads* (the four-place registration footgun). |
| `migration-helper` | The facts + troubleshooting around EF migrations; delegates the recipe to `create-migration`. |
| `code-reviewer` | Reviews the current diff against the conventions and emits a structured report (read-only). |
| `architecture-guard` | Verifies architectural integrity — module boundaries, BuildingBlocks protection, the architecture-test suite (read-only). |
You don't memorize skill names. Describe the task — *"add a CreateInvoice feature to Billing"* — and the
matching skill activates automatically. See
Developing with Claude Code.
## Extending the set
These are plain Markdown — add your own. A good **skill** is a verb (a repeatable task that can self-verify
with a build/test); a good **rule** is a noun (a convention you read). If you find yourself writing a
"reference" or "guide" skill, it's probably a rule. Keep skills pointing *at* the rules rather than
restating them, so there's a single source of truth.
---
# Overview
> Modular monolith, vertical slice architecture, module boundaries, and the composition root.
Source: https://fullstackhero.net/docs/architecture/
How fullstackhero is put together — the modular monolith, vertical slice architecture, and how modules talk to each other through contracts.
---
# Dependency injection & module loading
> How AddHeroPlatform, the module loader, the Mediator source generator, and FluentValidation auto-registration compose into a working host.
Source: https://fullstackhero.net/docs/architecture/dependency-injection/
fullstackhero's composition root is `src/Host/FSH.Starter.Api/Program.cs`, and it's short. Condensed:
```csharp
// src/Host/FSH.Starter.Api/Program.cs (condensed)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMediator(o =>
{
o.ServiceLifetime = ServiceLifetime.Scoped;
o.Assemblies = [
typeof(GenerateTokenCommand), typeof(GenerateTokenCommandHandler), // Identity — Contracts + runtime
typeof(GetTenantStatusQuery), typeof(GetTenantStatusQueryHandler), // Multitenancy — Contracts + runtime
/* …two marker types per module, for all ten modules… */ ];
});
var moduleAssemblies = new Assembly[]
{
typeof(IdentityModule).Assembly,
typeof(MultitenancyModule).Assembly,
/* …all ten module runtime assemblies… */
};
builder.AddHeroPlatform(o => { o.EnableCaching = true; o.EnableJobs = true; /* … */ });
builder.AddModules(moduleAssemblies);
var app = builder.Build();
app.UseHeroMultiTenantDatabases(); // Finbuckle UseMultiTenant() — before the rest of the pipeline
app.UseHeroPlatform(p => { p.MapModules = true; /* … */ });
await app.RunAsync();
```
That's the whole composition root. Everything underneath is the platform's job. This page is about what those calls actually do, the rules they impose, and the specific wiring failure that catches every team at least once.
## The four calls
1. **`builder.Services.AddMediator(o => ...)`** — registers Mediator 3's source-generated dispatch. `o.Assemblies` is a list of *marker types*: the generator scans the assembly each type lives in for `ICommand` / `IQuery` / handler implementations. Every module contributes **two** markers — one type from its `.Contracts` assembly and one from its runtime assembly. Handlers are registered with `ServiceLifetime.Scoped`.
2. **`builder.AddHeroPlatform(o => ...)`** — registers the cross-cutting platform: logging, OpenTelemetry, OpenAPI, CORS, security headers, API versioning, exception handling, the validation pipeline behavior, and any optional sub-blocks (caching, jobs, mailing, feature flags, idempotency, SSE, realtime, quotas) you opted into.
3. **`builder.AddModules(moduleAssemblies)`** — runs the `ModuleLoader`. It registers every FluentValidation validator in the supplied assemblies, discovers the assembly-level `[FshModule]` attributes, sorts modules by order, and invokes each one's `ConfigureServices(IHostApplicationBuilder)`.
4. **`app.UseHeroPlatform(p => ...)`** — wires the middleware pipeline in the right order, invoking each module's `ConfigureMiddleware` (right after authentication) and `MapEndpoints` (after authorization) in module order.
## The IModule contract
Every module implements `IModule` and declares itself with an **assembly-level** `[FshModule]` attribute (positional: module type, then order):
```csharp
// BuildingBlocks/Web/Modules/IModule.cs
public interface IModule
{
void ConfigureServices(IHostApplicationBuilder builder);
void MapEndpoints(IEndpointRouteBuilder endpoints);
void ConfigureMiddleware(IApplicationBuilder app) { } // optional — default no-op
}
```
```csharp
// Modules.Identity/AssemblyInfo.cs
[assembly: FshModule(typeof(FSH.Modules.Identity.IdentityModule), 100)]
```
```csharp
// Modules.Identity/IdentityModule.cs (condensed)
public class IdentityModule : IModule
{
public void ConfigureServices(IHostApplicationBuilder builder)
{
PermissionConstants.Register(IdentityPermissions.All);
builder.Services.AddScoped();
builder.Services.AddHeroDbContext();
// ...
}
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("api/v{version:apiVersion}/identity")
.WithTags("Identity")
.WithApiVersionSet(apiVersionSet);
group.MapGenerateTokenEndpoint().AllowAnonymous().RequireRateLimiting("auth");
group.MapRegisterUserEndpoint();
// ... the rest of the module's endpoints
}
}
```
The `ModuleLoader` runs `ConfigureServices` for **every** module in order, then later runs `ConfigureMiddleware` and `MapEndpoints` for every module in the same order. This two-phase wiring means a module can resolve dependencies registered by an earlier-order module during its own service registration.
## Mediator 3 — source-generated dispatch
Mediator 3 (the `martinothamar/Mediator` library, not to be confused with MediatR) is source-generated. At compile time, its generator walks `MediatorOptions.Assemblies`, finds every `ICommandHandler` and `IQueryHandler`, and emits a static dispatch table. **Zero runtime reflection.**
The catch: the generator only scans assemblies that appear in `o.Assemblies`. A module's commands live in its `.Contracts` assembly and its handlers live in its runtime assembly — so each module needs **both** markers in the list. Leave one out and nothing breaks at build time; the handler is just missing from the dispatch table, and `mediator.Send(...)` fails at runtime. The registration sites and the two-marker rule are documented in `.agents/rules/architecture.md` and the `mediator-reference` skill.
## FluentValidation auto-registration
`ModuleLoader.AddModules` calls `services.AddValidatorsFromAssemblies(moduleAssemblies)`, so every `AbstractValidator` in any module is registered automatically. The Mediator pipeline behavior `ValidationBehavior` (registered by `AddHeroPlatform`) runs them before every command handler.
What this means: drop a `MyCommandValidator : AbstractValidator` next to the handler, and validation runs on every `mediator.Send(new MyCommand(...))`. No registration line in your module's `ConfigureServices`.
## Three lifetimes, applied consistently
The kit uses standard `IServiceCollection` lifetimes:
| Lifetime | Use for | Example |
|---|---|---|
| Singleton | Stateless / app-wide / thread-safe | `IEventSerializer`, `ITenantInitialPasswordBuffer`, metrics/meter types |
| Scoped | Per-request DbContext, per-request user, Mediator handlers | `DbContext`, `ICurrentUser`, `ITokenService`, `IQuotaService`, command/query handlers (`o.ServiceLifetime = ServiceLifetime.Scoped`) |
| Transient | Stateless services that aren't expensive to construct | `IUserService` and the focused user sub-services, `IConnectionStringValidator` |
Two things to watch out for:
- **Hangfire jobs get a new scope per execution.** `FshJobActivator` creates an `IServiceScope` per job. So a scoped service injected into a job sees a fresh instance for each run. Don't try to share state via scoped services across job invocations; use Valkey or the database.
- **Integration event handlers run in their own scope, not the publisher's.** Handlers publish to the **outbox** (same transaction as the business write); `OutboxDispatcherHostedService` picks the row up asynchronously and the event bus creates a *fresh* DI scope per event before resolving handlers. Don't expect to share a DbContext or transaction with the publisher — idempotency comes from the Inbox (`{EventId, HandlerName}` dedupe), not shared state.
## Permission registration
`PermissionConstants` is a static registry. Each module calls it during `ConfigureServices`:
```csharp
PermissionConstants.Register(IdentityPermissions.All);
PermissionConstants.Register(CatalogPermissions.All);
PermissionConstants.Register(TicketsPermissions.All);
// ...
```
The registration is global, deduped by `Name`, and additive. There's no removal API — once registered, a permission is part of the runtime for the process lifetime.
The Identity module's `RequiredPermissionAuthorizationHandler` reads the permission from the endpoint metadata and checks it against the caller's claims. The kit's `RequiredPermissionAttribute` and `.RequirePermission()` fluent helper both produce that same metadata.
`RequiredPermissionAttribute` (in the Shared block) implements `IRequiredPermissionMetadata` — that interface is what the authorization handler looks up on the endpoint. If a copy of the attribute lands in another assembly **without implementing the interface**, every `.RequirePermission()` call silently stops gating. This is a runtime no-op, not a build break — and it's the single nastiest failure mode in the kit's auth pipeline. The handler carries a source comment warning about exactly this.
## The four wire points when you add a new module
Adding a new module touches four lists across **two host files**. Miss any one of them and it fails *silently*:
| # | Place | File | Symptom if missed |
|---|---|---|---|
| 1 | Mediator `o.Assemblies` — two markers (a Contracts type **and** the module type) | `src/Host/FSH.Starter.Api/Program.cs` | Handlers silently undiscovered |
| 2 | `moduleAssemblies` array | `src/Host/FSH.Starter.Api/Program.cs` | Module never loaded |
| 3 | Mediator assemblies (same pair) | `src/Host/FSH.Starter.DbMigrator/Program.cs` | Migrate/seed misses the module |
| 4 | Module assemblies array | `src/Host/FSH.Starter.DbMigrator/Program.cs` | Migrate/seed misses the module |
The DbMigrator pair is the one everyone forgets: it mirrors the API's registration because some module DbInitializers depend on services the Mediator pipeline builds. Forgetting #1 is the nastiest failure mode — the host boots, the module's services register, the endpoints map, but `mediator.Send(new MyCommand())` fails at runtime because the dispatch table doesn't include the handler. (You'll also add the new `*.Contracts` and runtime projects to `FSH.Starter.slnx`, but the compiler catches that one for you.)
## The middleware pipeline order
`app.UseHeroMultiTenantDatabases()` runs first, in `Program.cs` before `UseHeroPlatform` — it's Finbuckle's `UseMultiTenant()`, so tenant resolution happens before everything below (including authentication). Then `UseHeroPlatform` wires the pipeline in a deliberate order:
```
1. UseExceptionHandler → RFC 9457 ProblemDetails
2. UseResponseCompression
3. UseCors ← before HTTPS redirect (preflight)
4. UseHttpsRedirection
5. Security headers
6. UseStaticFiles (optional)
7. Hangfire dashboard (if jobs enabled)
8. UseRouting
9. OpenAPI + Scalar
10. UseAuthentication
11. Per-module ConfigureMiddleware ← multi-tenant root override etc.
12. UseRateLimiter
13. Quota enforcement (if quotas enabled)
14. UseAuthorization
15. Per-module MapEndpoints
16. Health, SSE, SignalR
17. CurrentUserMiddleware ← last, so authorization already done
```
Three ordering rules are unusual and important:
- **Tenant resolution before authentication.** Finbuckle's strategy chain sees an anonymous `User`, which is why claim-aware tenant logic lives in post-auth module middleware — see the [multitenancy deep dive](/docs/architecture/multitenancy-deep-dive/).
- **CORS before HTTPS redirect.** Preflight `OPTIONS` requests can't follow redirects per the Fetch spec.
- **Per-module middleware after authentication.** `UseModuleMiddlewares` runs each module's `ConfigureMiddleware` right after `UseAuthentication`, so module middleware (like Multitenancy's root-operator override) can read claims.
`CurrentUserMiddleware` runs last: it populates `ICurrentUser` from claims for the endpoint to consume. Anything reading `ICurrentUser` earlier in the chain sees nothing — middleware that needs the user reads claims directly.
## Related
- [Web building block](/docs/building-blocks/web/) — the composition + middleware code.
- [Vertical Slice](/docs/architecture/vertical-slice/) — what a handler looks like.
- [Modular monolith](/docs/architecture/modular-monolith/) — the module ordering rules.
---
# Modular monolith
> How fullstackhero composes ten modules into a single deployable process with hard module boundaries enforced by architecture tests.
Source: https://fullstackhero.net/docs/architecture/modular-monolith/
fullstackhero is a **modular monolith**. Ten modules — Identity, Multitenancy, Auditing, Files, Chat, Notifications, Webhooks, Billing, Catalog, Tickets — live in one repository, build one set of containers, and deploy as one process. Each is a bounded context with its own `DbContext`, its own feature folders, and a single one-way dependency channel (its `*.Contracts` assembly) through which the rest of the kit talks to it.
A modular monolith is its own architecture. You ship one process, one deploy, one set of logs, one set of traces — which is faster than running ten services for most teams. When a module hits a true horizontal-scaling need, the boundaries you've already drawn make extraction a refactor, not a rewrite.
## The shape
```
src/
├── BuildingBlocks/ Shared infrastructure (11 libraries)
│ ├── Core, Persistence, Web, Shared
│ ├── Caching, Eventing, Eventing.Abstractions
│ └── Jobs, Mailing, Storage, Quota
│
├── Modules/ Ten bounded contexts
│ ├── Identity/
│ │ ├── Modules.Identity/ Runtime — endpoints, handlers, domain
│ │ └── Modules.Identity.Contracts/ Public surface — commands, queries, DTOs, events
│ ├── Multitenancy/ (+ Contracts)
│ ├── Auditing/ (+ Contracts)
│ ├── Files/ (+ Contracts)
│ ├── Chat/ (+ Contracts)
│ ├── Notifications/ (+ Contracts)
│ ├── Webhooks/ (+ Contracts)
│ ├── Billing/ (+ Contracts)
│ ├── Catalog/ (+ Contracts)
│ └── Tickets/ (+ Contracts)
│
├── Host/ Composition root + orchestrator
│ ├── FSH.Starter.Api/ The API process
│ ├── FSH.Starter.AppHost/ .NET Aspire orchestrator
│ ├── FSH.Starter.DbMigrator/ Migrations + demo seeder CLI
│ └── FSH.Starter.Migrations.PostgreSQL/
│
└── Tools/CLI/ The `fsh` CLI (Spectre.Console)
```
Every module follows the same shape: a runtime project that does the work, a contracts project that defines its public surface, and (optionally) a migrations project for its EF Core schema.
## The boundary rule
Modules talk to each other **only through `*.Contracts` assemblies**.
| Allowed | Not allowed |
|---|---|
| `Modules.Chat.dll` references `Modules.Notifications.Contracts.dll` | `Modules.Chat.dll` references `Modules.Notifications.dll` |
| `Modules.Identity.dll` references `BuildingBlocks/*` | `Modules.Identity.dll` references any other module's runtime |
| Any module's `Contracts.dll` references `Eventing.Abstractions.dll` | A `Contracts.dll` references `Eventing` (the runtime) or EF Core |
The cardinal rule: **runtime modules never reference each other.** If module A needs something from module B, it asks module B's contracts — which is a thin layer of commands, queries, events, and DTOs.
These rules aren't conventions; they're enforced by `src/Tests/Architecture.Tests/`. The boundary test scans every module runtime `.csproj` for forbidden project references:
```csharp
// Architecture.Tests/ModuleArchitectureTests.cs (condensed)
[Fact]
public void Modules_Should_Not_Depend_On_Other_Modules()
{
// for every Modules.*.csproj (excluding *.Contracts), assert no ProjectReference
// points at another module's runtime project
isSelfReference.ShouldBeTrue(
$"Module runtime project '{currentName}' must not reference other module runtime project '{referencedName}'. " +
"Only contracts or building block projects are allowed.");
}
```
A second layer of [NetArchTest](https://github.com/BenMorris/NetArchTest) tests guards the finer rules — Contracts assemblies can't depend on EF Core, FluentValidation, or Hangfire; domain types can't reach into persistence. Break a rule and the test suite fails.
## How modules talk to each other
Three patterns, all one-way and contract-only:
### 1. Request a service (rare)
Some contracts expose service interfaces — `ITokenService`, `IUserService` (in `Modules.Identity.Contracts`) — that downstream modules can resolve via DI without referencing the runtime. The interface lives in `*.Contracts`; the implementation lives in the runtime; consumers depend only on the contract. (`ICurrentUser` works the same way, but lives one level down in `BuildingBlocks/Core`; Identity registers the implementation.)
### 2. Domain events (in-module)
Domain events are private to the aggregate's module. The `DomainEventsInterceptor` (from the Persistence block) dispatches them through Mediator after SaveChanges. Handlers live in the same module; cross-module dependency is zero.
### 3. Integration events (cross-module)
The canonical pattern. A module writes an `IIntegrationEvent` to the **outbox** (`IOutboxStore.AddAsync`, committed with the business write); the kit's outbox dispatcher publishes it asynchronously via `IEventBus`; any module that implements `IIntegrationEventHandler` receives a copy, with inbox-backed idempotency. Examples in the kit:
- **Chat → Notifications**: `MentionedInChannelIntegrationEvent` → inbox row + SignalR push.
- **Any module → Webhooks**: open-generic `WebhookFanoutHandler` fans every event to subscribed tenants.
- **Identity → outbox**: `UserRegisteredIntegrationEvent`, `TokenGeneratedIntegrationEvent` for downstream consumers.
The receiver doesn't know the producer. The producer doesn't know the receivers. That's the discipline.
## Module load order
Each module runtime carries an assembly-level attribute — `[assembly: FshModule(typeof(IdentityModule), 100)]`. Lower numbers run their `ConfigureServices` and `MapEndpoints` first. The ordering matters for two specific cases:
- **Cross-module integration-event handlers.** Notifications (order `750`) must load before Chat (order `800`) because Notifications registers handlers for Chat's `MentionedInChannelIntegrationEvent`. If Chat loaded first, the handler wouldn't be wired when the first mention event fires.
- **`ICurrentUser` availability.** Identity (order `100`) loads first so every later module sees the JWT pipeline and `ICurrentUser` registered.
| Order | Module |
|---|---|
| 100 | Identity |
| 200 | Multitenancy |
| 300 | Auditing |
| 350 | Files |
| 400 | Webhooks |
| 500 | Billing |
| 600 | Catalog |
| 700 | Tickets |
| 750 | Notifications |
| 800 | Chat |
The `ModuleLoader` (in `BuildingBlocks/Web`) runs each module's `ConfigureServices` in order, then later runs `ConfigureMiddleware` and `MapEndpoints` in the same order.
## When a module needs to scale out
The boundaries you've already drawn are the contract for extraction:
1. The module's runtime moves into its own service.
2. Its `*.Contracts` assembly is shared (either as a NuGet package or duplicated).
3. The cross-module communication switches from in-process Mediator (for service calls) and `InMemoryEventBus` (for events) to HTTP / RabbitMQ.
4. Consumers don't change — they still publish via `IEventBus`, still resolve services via DI. Only the transport behind them changes.
The kit ships `RabbitMqEventBus` as the cross-process implementation. Wire it in `EventingOptions:Provider = "RabbitMQ"` and you can run two halves of the monolith as two processes while keeping the surface identical.
You almost certainly don't need this for a long time. The point is that you don't have to commit to it on day one.
## Why not microservices on day one?
Two reasons.
**Operational cost.** Ten services means ten deploys, ten log streams, ten sets of dashboards, ten failure modes when one service is down. A modular monolith collapses that to one — and you get genuine isolation through your code structure, not your network topology.
**Wrong boundaries.** When you're shipping the first version of a SaaS, you don't yet know which boundaries are stable. Drawing service lines now means committing to API contracts that are about to change. A modular monolith lets you redraw boundaries in a refactor, not a coordinated multi-service deploy.
When a module's traffic pattern, scaling needs, or team ownership genuinely diverges from the rest — extract it. Until then, keep them on one box.
## Related
- [Vertical Slice Architecture](/docs/architecture/vertical-slice/) — what each module looks like internally.
- [Modules overview](/docs/modules/) — the ten that ship in v10.
- [Eventing.Abstractions](/docs/building-blocks/eventing-abstractions/) — the contract for cross-module talk.
- [Web building block](/docs/building-blocks/web/) — the module loader.
---
# Multitenancy deep dive
> How tenancy is the default in every layer — Finbuckle strategies, EF Core global query filter, IGlobalEntity opt-out, named filters, cross-tenant query discipline.
Source: https://fullstackhero.net/docs/architecture/multitenancy-deep-dive/
Multitenancy isn't a module in fullstackhero. It's a property of the platform — every EF Core entity is tenant-aware by default, every cache is tenant-scoped, every background job carries the tenant context that enqueued it, and every audit row records which tenant it belongs to. You opt **out** with `IGlobalEntity`, not in.
This page explains how that works mechanically, the design decisions behind it, and the rules for the rare cross-tenant query.
## Three layers, one default
Tenancy is enforced at three layers, each protecting the next:
| Layer | Where | What enforces it |
|---|---|---|
| HTTP request | Multitenancy module (`UseHeroMultiTenantDatabases()` in `Program.cs`) | Finbuckle's `UseMultiTenant()` resolves the tenant from the `tenant` header or `?tenant=` query and sets the context — first thing in the pipeline. |
| Data access | `BuildingBlocks/Persistence` | `BaseDbContext` applies Finbuckle's tenant query filter to every entity that doesn't opt out. |
| Background work | `BuildingBlocks/Jobs` | `FshJobFilter` captures the tenant at enqueue; `FshJobActivator` restores it before the job runs. |
You don't write `WHERE tenant_id = @tenantId` in your queries. The filter is on every entity, every time, unless you explicitly bypass it.
## Layer 1 — Tenant resolution
Finbuckle.MultiTenant 10 resolves the tenant on every request. The kit configures the chain:
```csharp
// MultitenancyModule.ConfigureServices (simplified)
builder.Services.AddMultiTenant()
.WithClaimStrategy(ClaimConstants.Tenant) // 1. claim — no-op pre-auth (see below)
.WithHeaderStrategy(MultitenancyConstants.Identifier) // 2. "tenant" header — the primary resolver
.WithDelegateStrategy(/* ?tenant= query fallback */) // 3. query string
.WithDistributedCacheStore(TimeSpan.FromMinutes(60))
.WithStore>(ServiceLifetime.Scoped);
```
Finbuckle's strategy chain runs **before** `UseAuthentication()`. The claim strategy therefore sees an anonymous `User` on every normal request and is effectively a no-op. It's there for the **cross-tenant impersonation** case, where post-auth middleware re-resolves the tenant from the impersonation grant's claim. In the normal flow the header strategy is the primary resolver.
The kit caches resolved tenants in `DistributedCacheStore` for 60 minutes (Valkey backplane) to keep hot-path requests off the DB. The `EFCoreStore` is the source of truth.
### The root-operator override
A SuperAdmin (whose JWT carries `tenant=root`) can scope a single request to another tenant. This is needed for cross-tenant admin operations like searching users in tenant X before starting impersonation.
The kit implements this as **post-auth middleware**, not as a Finbuckle strategy — because the strategy chain runs before auth, it can't tell whether the caller is actually the root user. The middleware lives in `MultitenancyModule.ConfigureMiddleware` (which `UseModuleMiddlewares` runs right after `UseAuthentication`): it checks the caller's `tenant` claim is `root`, reads the `tenant` header, looks the target up in the store, and swaps the resolved context via `IMultiTenantContextSetter`.
The same `ConfigureMiddleware` also hosts the **deactivated/expired-tenant guard**: every request from a non-root tenant is rejected if the tenant is inactive or past `ValidUpto` plus the configured grace window — enforced per request, not just at login.
## Layer 2 — Data access
`BaseDbContext` (in `BuildingBlocks/Persistence`) is the EF Core base every module's `DbContext` inherits. Its `OnModelCreating` does two things:
```csharp
// BuildingBlocks/Persistence/Context/BaseDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
ArgumentNullException.ThrowIfNull(modelBuilder);
modelBuilder.AppendGlobalQueryFilter(QueryFilters.SoftDelete, s => !s.IsDeleted);
base.OnModelCreating(modelBuilder);
// Default-on tenant isolation: entities not marked IGlobalEntity get IsMultiTenant().
modelBuilder.ApplyTenantIsolationByDefault();
}
```
`ApplyTenantIsolationByDefault()` walks every entity that doesn't implement `IGlobalEntity` and calls `IsMultiTenant().AdjustUniqueIndexes()` on it — Finbuckle's hook to install the tenant filter (and widen unique indexes to include the tenant column). The result: a query like `await db.Products.ToListAsync(ct)` automatically becomes `WHERE tenant_id = @currentTenantId`.
The filters are applied inside `BaseDbContext.OnModelCreating`. A module
`DbContext` that overrides `OnModelCreating` to add its own entity configuration
must call `base.OnModelCreating(modelBuilder)` **last** — EF only honors a query
filter declared after the entity is mapped. Call `base` first (before your
`ApplyConfigurationsFromAssembly`) and the tenant + soft-delete filters silently
fail to attach, and every query leaks across tenants. This is the single most
common way to accidentally break isolation in a new module.
`SaveChangesAsync` sets `TenantNotSetMode = TenantNotSetMode.Overwrite` so any entity entering Added state without an explicit `TenantId` inherits the current tenant. No `entity.TenantId = current.GetTenant()` lines in your handlers.
### Two opt-outs
**`IGlobalEntity`** — for entities that need to live across tenants. The kit uses it for:
- `BillingPlan` — the plan catalogue is platform-wide
- `ImpersonationGrant` — operator impersonation spans tenants
- `OutboxMessage`, `InboxMessage` — eventing infrastructure
If you mark an entity `IGlobalEntity`, the global tenant filter is **not** applied. Queries against the table return rows for every tenant; access control becomes the handler's responsibility.
**`IgnoreQueryFilters([QueryFilters.SoftDelete])`** — the kit registers SoftDelete as a *named* filter (the tenant filter stays anonymous, Finbuckle-owned), so individual queries can drop just the soft-delete filter without touching the tenant filter:
```csharp
var trashed = await db.Products
.IgnoreQueryFilters([QueryFilters.SoftDelete])
.Where(p => p.IsDeleted)
.ToListAsync(ct).ConfigureAwait(false);
```
This is the pattern for "list trashed items" queries (see `ListTrashedProductsQueryHandler` and friends). The tenant filter still applies; the soft-delete filter doesn't.
### Cross-tenant queries (rare, but real)
Auditing needs to query across tenants — a root operator pulling another tenant's audit trail. The real code, from the Auditing module's `GetAuditsQueryHandler`:
```csharp
// Cross-tenant access requires an explicit permission check first…
var allowed = await _permissions
.HasPermissionAsync(userId, AuditingPermissions.AuditTrails.ViewCrossTenant, ct)
.ConfigureAwait(false);
if (!allowed)
{
throw new ForbiddenException("Cross-tenant audit access requires Permissions.AuditTrails.ViewCrossTenant.");
}
// …then bypass the filter and IMMEDIATELY re-scope to the requested tenant.
return _dbContext.AuditRecords
.AsNoTracking()
.IgnoreQueryFilters()
.Where(a => a.TenantId == requested);
```
The pattern: gate with an explicit permission, use `IgnoreQueryFilters()` deliberately, then **re-filter explicitly** to the exact scope the caller asked for — never return rows for *all* tenants. Don't sprinkle this; reserve it for specific admin / audit query handlers.
## Layer 3 — Background work
Hangfire jobs run on threads that have no `HttpContext`, so the tenant context resolved on the originating request is gone by the time the job dequeues. Two pieces in `BuildingBlocks/Jobs` handle this:
- **At enqueue time** — `FshJobFilter` reads the current tenant (via `IMultiTenantContextAccessor`) and user id, and stores them as Hangfire job parameters.
- **At execute time** — `FshJobActivator` reads the stored parameters back and pushes the tenant onto `IMultiTenantContextSetter` (and the user onto `ICurrentUserInitializer`) before the job body runs. The job sees the same tenant the request did.
The same pattern shows up in integration-event handlers: `WebhookFanoutHandler` installs the event's `TenantId` via `IMultiTenantContextSetter` before querying the subscription table, then restores the previous context in a `finally`. Forget this and the Finbuckle query filter returns no rows on the background thread.
## Per-tenant connection strings
Each `AppTenantInfo` row carries an optional `ConnectionString`. When set, `BaseDbContext.OnConfiguring` switches the DbContext to that connection at runtime. This lets a high-value tenant get its own database without changing application code.
When not set, all tenants share the kit's main database with row-level isolation via the global query filter. For most teams, row-level isolation is sufficient through the kit's lifecycle and scales further than you'd expect — Postgres handles hundreds of thousands of tenants in one database fine. Reach for per-tenant databases only when you have a compliance requirement (HIPAA, PCI-DSS) or a single tenant whose data volume genuinely needs its own physical store.
## Caching tenant-scoped data
The Caching block (`HybridCache`) is tenant-aware **by convention**: include `tenantId` in every cache key. The kit's pattern:
```csharp
await cache.GetOrCreateAsync(
$"product:{tenantId}:{productId}",
async (ct) => await db.Products.FirstAsync(...),
tags: [$"tenant:{tenantId}"],
cancellationToken: ct).ConfigureAwait(false);
```
The tag-based eviction lets you invalidate a tenant's entire cache on plan change, suspension, or sign-out (`CacheKeys.Tags.Tenant(...)` is the kit's helper for the tag):
```csharp
await cache.RemoveByTagAsync(CacheKeys.Tags.Tenant(tenantId), ct).ConfigureAwait(false);
```
L1 (in-memory) cache has no backplane; the kit's `DefaultLocalCacheExpiration` (2 minutes) bounds the cross-node staleness window after a peer's `RemoveByTag`.
## The documented exception: Billing
One module deliberately steps outside the default. `BillingDbContext` is a plain
`DbContext` — **not** `BaseDbContext` — so the auto-applied tenant filter does
**not** govern it. Subscriptions, invoices, and usage snapshots instead carry an
explicit `TenantId` column, and the billing query services filter on it by hand.
This is intentional: billing is a finance/admin concern that routinely queries
*across* tenants — the monthly invoice job iterates every active tenant, and
operators pull cross-tenant invoice reports. Forcing it through the per-request
tenant filter would fight the feature. The trade-off is that **billing handlers
own their tenant scoping**: every query that should be tenant-bound must say so
(`.Where(x => x.TenantId == current)`), because nothing does it for them.
The plan catalogue (`BillingPlan`) goes further and is `IGlobalEntity` — a single
platform-wide list of plans shared by all tenants. See the [Billing module](/docs/modules/billing/).
Default to `BaseDbContext` (isolation on). Reach for a plain `DbContext` with a
manual `TenantId` column only when the module's whole job is cross-tenant
administration — and document it, like Billing does.
## Per-tenant migrations & provisioning
Because each tenant can have its own schema (and optionally its own database),
migrations run per tenant. The [DbMigrator](/docs/deployment/database-migrations/)
applies the **tenant catalog first**, then walks **each tenant's per-module
schema**, serialized by a Postgres advisory lock so two migrator instances can't
collide.
New tenants are provisioned asynchronously. `CreateTenantCommand` returns
immediately, buffers the operator-supplied admin password in
`ITenantInitialPasswordBuffer` (a singleton — there is no hard-coded default
password), and queues a Hangfire job that walks ordered, resumable steps
(Database → Migrations → Seeding → CacheWarm). A tenant is only **activated once
provisioning reaches `Completed`**; a failed step is resumable via
`RetryTenantProvisioning`. Full lifecycle in the [Multitenancy module](/docs/modules/multitenancy/).
## Testing isolation
Isolation is verified, not assumed. Integration tests at
`src/Tests/Integration.Tests/Tests/Multitenancy/` assert that data created under
tenant A is invisible to tenant B, that the header override is gated to the root
operator, and that seeding is tenant-scoped.
Finbuckle's tenant context flows through `AsyncLocal`. In an integration test,
set it **in the same method** as the `DbContext` / `UserManager` call — if you
set it inside an awaited helper, the value is lost across the async boundary and
the tenant query filter NREs (it has no tenant to filter on). This is the most
common cause of a flaky tenant test.
## What tenancy is NOT
- **Tenant isolation is not a security boundary against compromised code.** A bug that calls `IgnoreQueryFilters()` returns cross-tenant data. Architecture tests can catch obvious cases; code review catches the rest. Keep the bypass calls audited.
- **Tenant context is not propagated to outbound HTTP calls automatically.** When you call a third-party API on behalf of a tenant, attach the tenant id explicitly to the request (header, OAuth scope, audit metadata).
- **Tenant identity is not user identity.** A user can belong to multiple tenants (the operator impersonation case). Use `ICurrentUser.GetUserId()` for user identity; use `ICurrentUser.GetTenant()` for the resolved tenant of the current request.
## Related
- [Multitenancy module](/docs/modules/multitenancy/) — the module that owns tenant lifecycle.
- [Billing module](/docs/modules/billing/) — the documented cross-tenant exception.
- [Database Migrations](/docs/deployment/database-migrations/) — per-tenant migration ordering.
- [Persistence](/docs/building-blocks/persistence/) — `BaseDbContext` and the auto-applied filters.
- [Jobs](/docs/building-blocks/jobs/) — `FshJobFilter` and tenant restoration.
- [Webhooks module](/docs/modules/webhooks/) — the cross-thread "restore tenant context manually" pattern.
---
# Vertical Slice Architecture
> How each module is structured inside — one feature, one folder, one PR; endpoint + command + handler + validator + tests living together.
Source: https://fullstackhero.net/docs/architecture/vertical-slice/
Inside every fullstackhero module, **Vertical Slice Architecture** (VSA) determines the shape. A feature — registering a user, changing a product price, sending a chat message — lives in one feature folder: the endpoint, the handler, and the validator together, with the command and response mirrored in the module's `.Contracts` project. No layered round-trip. No Domain → Application → Infrastructure → API hops. One feature, one folder, one PR.
In Vertical Slice the answer is "one folder, maybe two files." In a layered architecture the answer is "one DTO, one application service, one domain method, one infrastructure repository, one controller method, and probably four interface declarations to wire them." VSA optimises for the change you actually make.
## The slice shape
A slice is split across the module's two projects. `RegisterUser` looks like this:
```
Modules.Identity.Contracts/v1/Users/RegisterUser/
├── RegisterUserCommand.cs ← public surface (ICommand)
└── RegisterUserResponse.cs ← public surface (returned by the handler)
Modules.Identity/Features/v1/Users/RegisterUser/
├── RegisterUserEndpoint.cs ← minimal API mapping
├── RegisterUserCommandHandler.cs ← Mediator handler
└── RegisterUserCommandValidator.cs ← FluentValidation
```
The command + response live in Contracts because they're the public surface anyone (including other modules) reads. The endpoint, handler, and validator live in the runtime project because they're private to the module.
Tests for the slice live in the module's test project (e.g. `src/Tests/Identity.Tests/Handlers/RegisterUserCommandHandlerTests.cs`), and `src/Tests/Architecture.Tests/` enforces the structural rules — handlers paired with validators, Contracts free of EF/FluentValidation, versioned features not reaching into newer versions.
## A complete slice
The cleanest way to learn VSA is to read one slice end-to-end. Here's `RegisterUser`, condensed:
```csharp
// Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserCommand.cs
public class RegisterUserCommand : ICommand
{
public string FirstName { get; set; } = default!;
public string LastName { get; set; } = default!;
public string Email { get; set; } = default!;
public string UserName { get; set; } = default!;
public string Password { get; set; } = default!;
public string ConfirmPassword { get; set; } = default!;
public string? PhoneNumber { get; set; }
[JsonIgnore]
public string? Origin { get; set; } // set by the endpoint, not the client
}
// Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserResponse.cs
public record RegisterUserResponse(string UserId);
```
```csharp
// Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs
public sealed class RegisterUserCommandValidator : AbstractValidator
{
public RegisterUserCommandValidator()
{
RuleFor(x => x.FirstName).NotEmpty().MaximumLength(100);
RuleFor(x => x.LastName).NotEmpty().MaximumLength(100);
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.UserName).NotEmpty().MinimumLength(3).MaximumLength(50);
RuleFor(x => x.Password).NotEmpty().MinimumLength(6);
RuleFor(x => x.ConfirmPassword).NotEmpty()
.Equal(x => x.Password).WithMessage("Passwords do not match.");
}
}
```
```csharp
// Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandHandler.cs
public sealed class RegisterUserCommandHandler : ICommandHandler
{
private readonly IUserService _userService;
public RegisterUserCommandHandler(IUserService userService) => _userService = userService;
public async ValueTask Handle(RegisterUserCommand command, CancellationToken cancellationToken)
{
string userId = await _userService.RegisterAsync(
command.FirstName, command.LastName, command.Email, command.UserName,
command.Password, command.ConfirmPassword,
command.PhoneNumber ?? string.Empty, command.Origin ?? string.Empty,
cancellationToken).ConfigureAwait(false);
return new RegisterUserResponse(userId);
}
}
```
```csharp
// Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs
public static class RegisterUserEndpoint
{
internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints)
{
return endpoints.MapPost("/register", async (RegisterUserCommand command,
HttpContext context, IMediator mediator, CancellationToken cancellationToken) =>
{
command.Origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}";
var result = await mediator.Send(command, cancellationToken);
return TypedResults.Created($"/api/v1/identity/users/{result.UserId}", result);
})
.WithName("RegisterUser")
.WithSummary("Register user")
.RequirePermission(IdentityPermissions.Users.Create)
.WithIdempotency(); // Idempotency-Key header support
}
}
```
That's the entire feature. Five small files. One slice. One PR.
## Conventions
The kit enforces a small set of conventions through code review and architecture tests:
- **Handlers are `public sealed`.** Mediator's source generator requires concrete types; `sealed` is the default modifier so nothing inherits handlers.
- **Handlers return `ValueTask`.** Allocation-friendly for the synchronous fast path.
- **Every `await` uses `.ConfigureAwait(false)`.** This is library code; never capture the synchronization context.
- **Commands and queries are `record`s or `sealed` classes.** Enforced by an architecture test (`Commands_And_Queries_Should_Be_Records_Or_Sealed`). Prefer records; reach for a class when the endpoint needs to enrich the command (like `RegisterUserCommand.Origin` above).
- **Endpoints are static extension methods on `IEndpointRouteBuilder`.** No controllers. No `[ApiController]`. Just minimal APIs.
- **One endpoint per slice.** A slice represents one feature; one feature has one HTTP entrypoint. `ChangeProductPrice` has `PATCH /products/{productId:guid}/price`; `UpdateProduct` has `PUT /products/{productId:guid}` — separate slices because they're separate operations with separate events.
- **Validators live next to handlers.** The module loader auto-registers FluentValidation validators from each module's assembly.
- **Permissions live in `Contracts/Authorization/`.** Endpoints reference them via `.RequirePermission(...)`. Permission constants are registered once during module startup.
## Why not layered Clean Architecture?
Clean Architecture (Jason Taylor, Ardalis) is a valid alternative — many teams use it well. The kit picks VSA because most production changes are **cohesive feature changes**, not cross-cutting refactors. When a stakeholder asks "can we add a discount code to product pricing?", you want one folder to open, not five.
The trade-off: cross-cutting refactors (rename `Product.Sku` to `Product.SkuCode` everywhere) are slightly more tedious in VSA because the affected slices are scattered. Modern IDEs handle this fine; if your team makes a lot of cross-cutting changes, layered architecture is a better fit. If your team ships features more than it refactors abstractions, VSA wins.
The kit's three architectural pillars — modular monolith, vertical slice inside each module, integration events across modules — combine well. Each module is a clean unit of ownership; each slice is a clean unit of change.
## CQRS without ceremony
VSA goes well with CQRS — separating commands (writes that emit events) from queries (reads that project data). The kit uses Mediator 3 (source-generated, no runtime reflection) so the distinction is just two marker interfaces:
```csharp
public sealed record CreateProductCommand(...) : ICommand;
public sealed record GetProductByIdQuery(Guid Id) : IQuery;
```
Handlers implement `ICommandHandler` or `IQueryHandler`. Mediator's source generator wires the dispatch table at compile time. There's no `IRequestHandler<,>` magic, no runtime reflection, no startup scan.
For most slices, command and query handlers look almost the same — they take a DTO, do the work, return a DTO. The point isn't ceremony, it's intent: a `Query` handler is allowed to skip `SaveChanges`; a `Command` handler is allowed to `RaiseDomainEvent`. Keeping the convention makes the read / write distinction obvious to future readers.
## What VSA doesn't do
- **It doesn't replace your domain model.** Aggregates still live in the `Domain/` folder; the slice's handler invokes domain methods. VSA is about how features are organized; it has no opinion on how aggregates enforce invariants.
- **It doesn't enforce database isolation.** Each module's `DbContext` is its own slice of the DB, but inside a module multiple slices share the same `DbContext`. Use the Specification pattern (from the Persistence block) to keep queries reusable.
- **It doesn't make architecture tests optional.** Slices spread the logic; arch tests make sure the spread doesn't drift into chaos. Read `src/Tests/Architecture.Tests/` to see what's enforced.
## Related
- [Modular monolith](/docs/architecture/modular-monolith/) — the outer structure that contains the slices.
- [Modules overview](/docs/modules/) — every module is structured this way.
- [Catalog module](/docs/modules/catalog/) — the most slice-dense module; read its `Features/v1/` for examples.
- [Dependency injection](/docs/architecture/dependency-injection/) — how the module loader wires slice handlers.
---
# Overview
> Shared framework libraries — Core, Persistence, Web, Caching, Eventing, Jobs, Mailing, Storage, Quota.
Source: https://fullstackhero.net/docs/building-blocks/
The shared libraries every module composes against. Stable, opinionated, and meant to be read.
---
# Caching building block
> HybridCache (L1 in-memory + L2 Valkey or distributed memory) with OpenTelemetry instrumentation, stampede protection, and a shared multiplexer for DataProtection.
Source: https://fullstackhero.net/docs/building-blocks/caching/
The Caching block wires `Microsoft.Extensions.Caching.Hybrid` for the whole platform. L1 is in-process memory; L2 is **Valkey** (a Redis-compatible, BSD-licensed Redis fork) when `CachingOptions:Redis` is set, otherwise the in-process `DistributedMemoryCache`. The block decorates the underlying `HybridCache` with `ObservableHybridCache` to emit OpenTelemetry metrics + activities, and it shares the `IConnectionMultiplexer` with DataProtection key persistence so the whole host opens **one Valkey connection pool**, not one per feature.
HybridCache coalesces concurrent identical requests into a single source fetch automatically. The first caller blocks on the source; the rest of the wave wait on the same task. You don't write lock code; you just call `GetOrCreateAsync` and the cache does the right thing.
## What it ships
### Extension
- **`AddHeroCaching(services, configuration)`** — wires HybridCache + L2 + the OTel decorator. Reads `CachingOptions`.
### Decorator
- **`ObservableHybridCache(inner)`** — wraps the framework `HybridCache` to emit:
- `fsh.cache.hits` / `fsh.cache.misses` (OTel counters; L1 and L2 hits are both counted as "hit" — HybridCache doesn't surface which layer served the read)
- `fsh.cache.invalidations` (OTel counter; `RemoveAsync` + `RemoveByTagAsync`)
- `fsh.cache.factory.duration` (OTel histogram, ms; recorded only on a miss)
- Activities under `ActivitySource("FSH.Caching")` with `cache.system`, `cache.key`, `cache.hit` (or `cache.tag`) tags.
### Options
- **`CachingOptions`**:
- `Redis` — connection string; empty falls back to in-memory `DistributedMemoryCache`
- `EnableSsl` — TLS toggle (defaults to inference from the connection string)
- `DefaultExpiration` — TimeSpan; default 1 hour, applies to L1 + L2
- `DefaultLocalCacheExpiration` — TimeSpan; default 2 minutes; bounds cross-node staleness after RemoveByTag on peers
- `MaximumKeyLength` — default 1024 chars; longer keys are rejected
- `MaximumPayloadBytes` — default 1 MB; larger payloads are silently skipped
### Telemetry helper
- **`CachingTelemetry`** — the `ActivitySource` + meter the decorator emits (both named `FSH.Caching`, exposed as `ActivitySourceName` / `MeterName` constants). Wire them into the OTel pipeline via `metrics.AddMeter(...)` / `tracing.AddSource(...)`.
## How modules consume it
Inject `HybridCache` and call `GetOrCreateAsync`:
```csharp
public sealed class GetTenantConfigQueryHandler(HybridCache cache, ITenantConfigRepo repo)
: IQueryHandler
{
public ValueTask Handle(GetTenantConfigQuery q, CancellationToken ct)
=> cache.GetOrCreateAsync(
$"tenant-config:{q.TenantId}",
async (innerCt) => await repo.LoadAsync(q.TenantId, innerCt).ConfigureAwait(false),
cancellationToken: ct);
}
```
For tag-based invalidation:
```csharp
await cache.GetOrCreateAsync(key, factory, tags: ["tenant:" + tenantId.ToString()], cancellationToken: ct);
// later, on tenant config change
await cache.RemoveByTagAsync("tenant:" + tenantId.ToString(), ct).ConfigureAwait(false);
```
Identity uses HybridCache for per-user permission sets (`UserPermissionService`, warmed by the `RolePermissionSyncer`) and impersonation grants (`ImpersonationGrantService`); Multitenancy caches tenant themes (`TenantThemeService`) and fronts the tenant EF store with Finbuckle's `DistributedCacheStore`, which rides on the same `IDistributedCache` this block registers.
## Configuration
```jsonc
{
"CachingOptions": {
"Redis": "localhost:6379",
"DefaultExpiration": "01:00:00",
"DefaultLocalCacheExpiration": "00:02:00",
"MaximumKeyLength": 1024,
"MaximumPayloadBytes": 1048576
}
}
```
When `Redis` is empty the block falls back to in-memory L2 (`DistributedMemoryCache`) — fine for dev/test, **not** for multi-instance production. Always set Valkey (or another distributed cache) for production.
## How to extend
### Use the shared multiplexer for your own Valkey work
The block exposes the `IConnectionMultiplexer` through DI; resolve it and reuse instead of opening a second pool:
```csharp
public sealed class MyRedisService(IConnectionMultiplexer redis)
{
public async Task ReadAsync(string key)
{
var db = redis.GetDatabase();
return await db.StringGetAsync(key);
}
}
```
### Add another decorator
Decorate the registered `HybridCache` with another wrapper to add e.g. circuit-breaker logic. Pull the existing descriptor out of the service collection, register your decorator, and chain — `ObservableHybridCache` does this exact dance for OTel.
### Pre-warm on startup
Resolve `HybridCache` from a hosted service and `GetOrCreateAsync` your hot keys before traffic arrives. The Identity module's `RolePermissionSyncHostedService` is the template.
## Gotchas
- **L1 has no backplane.** After `RemoveByTag` on one instance, peer instances' L1 copies aren't invalidated. The `DefaultLocalCacheExpiration` (2 min default) bounds the staleness window — don't set it too high if you rely on fast invalidation.
- **Payload size cap is silent.** Entries larger than `MaximumPayloadBytes` are skipped (with a warning log), not stored. If your hot-path cache value is suddenly 1.5 MB, lookup will miss every time. Log-search for "payload too large" warnings before assuming the cache is broken.
- **One multiplexer, two consumers.** Caching and DataProtection key persistence share the same `IConnectionMultiplexer`. The SignalR backplane (`AddHeroRealtime`) reads the same `CachingOptions:Redis` string but opens its **own** connection — so a multi-instance host with realtime enabled holds two Valkey connections, not one.
- **`DistributedMemoryCache` is per-process.** Two instances of the API can't see each other's cache. This is intentional for test fixtures; if you accidentally ship it to multi-instance prod, multi-tenant cache invalidation breaks. Always check `CachingOptions:Redis` is set in production config.
## Critical files
- `src/BuildingBlocks/Caching/Extensions.cs`
- `src/BuildingBlocks/Caching/ObservableHybridCache.cs`
- `src/BuildingBlocks/Caching/CachingOptions.cs`
- `src/BuildingBlocks/Caching/Telemetry/CachingTelemetry.cs`
## Related
- [Web](/docs/building-blocks/web/) — `FshPlatformOptions.EnableCaching` toggle.
- [Multitenancy module](/docs/modules/multitenancy/) — uses HybridCache as the `DistributedCacheStore`.
- [Chat module](/docs/modules/chat/) — typing-indicator throttle uses the cache.
---
# Core building block
> The foundational types every module composes against — BaseEntity, AggregateRoot, DomainEvent, ICurrentUser, and the CustomException hierarchy.
Source: https://fullstackhero.net/docs/building-blocks/core/
`FSH.Framework.Core` (package id) is the foundation every other module and building block depends on. It ships nothing runtime-active — no DI registrations, no middleware, no hosted services — just the **shared abstractions** every domain layer needs: entity bases, aggregate-root markers, the `DomainEvent` record, current-user / tenant interfaces, and the framework's exception hierarchy. Around **a few hundred lines** of interface + abstract-class definitions.
If you're writing a new module, the first file you'll touch is the domain. Every aggregate inherits from one of the bases here. Get familiar with the contracts and the rest of the kit reads naturally.
## What it ships
### Entity bases
- **`BaseEntity`** — abstract; carries `Id` (protected setter), a private domain-event list surfaced as `DomainEvents`, a protected `AddDomainEvent(IDomainEvent)`, and `ClearDomainEvents()`. Every persisted entity inherits from this.
- **`AggregateRoot`** — extends `BaseEntity`; a semantic marker for aggregate roots. It adds no members today — it exists so the type system says "this is a consistency boundary" and gives you a place for aggregate-wide helpers later.
- **`IEntity`** — the minimal entity contract: a single `TId Id { get; }`.
### Cross-cutting interfaces
- **`IHasTenant`** — `string TenantId { get; }`. A read-side convenience for entities that expose their tenant id as a real property. Note: tenant isolation itself does **not** key off this interface — `BaseDbContext` marks every entity `IsMultiTenant()` via Finbuckle unless it implements `IGlobalEntity` (see [Persistence](/docs/building-blocks/persistence/)).
- **`IAuditableEntity`** — `CreatedOnUtc`, `CreatedBy`, `LastModifiedOnUtc`, `LastModifiedBy` (all `DateTimeOffset`-based). `AuditableEntitySaveChangesInterceptor` fills them in automatically.
- **`ISoftDeletable`** — `IsDeleted`, `DeletedOnUtc`, `DeletedBy`. The named query filter `QueryFilters.SoftDelete` hides deleted rows by default; opt out surgically with `IgnoreQueryFilters([QueryFilters.SoftDelete])` (tenant scoping stays in force).
- **`IHasDomainEvents`** — implemented by `BaseEntity`; surfaces the domain-event list so the `DomainEventsInterceptor` can dispatch them after SaveChanges.
- **`IGlobalEntity`** — opt-out of tenant isolation for platform-wide rows (billing plans, impersonation grants, outbox/inbox messages).
### Domain events
`DomainEvent` is an abstract record:
```csharp
// src/BuildingBlocks/Core/Domain/DomainEvent.cs
public abstract record DomainEvent(
Guid EventId,
DateTimeOffset OccurredOnUtc,
string? CorrelationId = null,
string? TenantId = null) : IDomainEvent
{
public static T Create(Func factory)
where T : DomainEvent
=> factory(Guid.NewGuid(), DateTimeOffset.UtcNow);
}
```
Concrete events inherit it (this one is real, from Catalog):
```csharp
public sealed record ProductPriceChangedDomainEvent(
Guid ProductId,
decimal OldPrice,
decimal NewPrice,
string Currency,
Guid EventId,
DateTimeOffset OccurredOnUtc) : DomainEvent(EventId, OccurredOnUtc);
```
…and aggregates raise them inside their methods. The `DomainEventsInterceptor` dispatches via Mediator after `SaveChangesAsync` completes.
### Current-user + request context
- **`ICurrentUser`** — the request-time view of the authenticated user: `Name`, `GetUserId()`, `GetUserEmail()`, `GetTenant()`, `IsAuthenticated()`, `IsInRole(role)`, `GetUserClaims()`.
- **`ICurrentUserInitializer`** — implemented by the Identity module's `CurrentUserService`; populates `ICurrentUser` from the `ClaimsPrincipal` (`SetCurrentUser`) or a raw id (`SetCurrentUserId`, used by Hangfire's job activator). The kit's `CurrentUserMiddleware` calls `SetCurrentUser` right after authentication, before the endpoint runs.
- **`IRequestContext`** — request metadata without an ASP.NET Core dependency: `IpAddress`, `UserAgent`, `ClientId` (from the `X-Client-Id` header), `Origin`.
### Exception hierarchy
The kit's global exception handler (in the Web block) turns these into `ProblemDetails` (RFC 9457):
| Type | Status | Use when |
|---|---|---|
| `CustomException(message, errors, statusCode)` | configurable (default 500) | Generic domain failure; pick the status |
| `NotFoundException` | 404 | Resource missing |
| `ForbiddenException` | 403 | Authenticated but not authorised |
| `UnauthorizedException` | 401 | Not authenticated |
All four are plain exception classes. `CustomException` carries an `ErrorMessages` list (validation errors, business-rule details) and a `StatusCode`, and has overloads that accept an inner exception when you're wrapping a lower-level failure. Domain code throws them; the handler does the HTTP translation.
## How modules consume Core
Every aggregate inherits one of the bases. Every persisted entity opts in or out of the cross-cutting interfaces:
```csharp
// Example from Catalog (no TenantId property needed — Finbuckle adds the
// tenant column and filter automatically via BaseDbContext)
public sealed class Product : AggregateRoot, ISoftDeletable
{
public bool IsDeleted { get; private set; }
public DateTimeOffset? DeletedOnUtc { get; private set; }
public string? DeletedBy { get; private set; }
public void ChangePrice(Money newPrice)
{
decimal oldAmount = Price.Amount;
Price = newPrice;
AddDomainEvent(DomainEvent.Create((id, ts) =>
new ProductPriceChangedDomainEvent(Id, oldAmount, newPrice.Amount, newPrice.Currency, id, ts)));
}
}
```
`ICurrentUser` is injected by constructor anywhere you need to attribute work to a user:
```csharp
public sealed class StartImpersonationCommandHandler(IIdentityDbContext db, ICurrentUser current)
: ICommandHandler
{
public async ValueTask Handle(StartImpersonationCommand cmd, CancellationToken ct)
{
var actor = current.GetUserId(); // never null inside an authenticated endpoint
// ...
}
}
```
## How to extend
### Add a new cross-cutting interface
If you want every entity to track, say, `LastViewedAt`, add `IHasLastViewed` to Core, add the auto-update logic to a new EF Core interceptor in Persistence, and implement the interface on the entities that care. The kit's pattern is: cross-cutting capability = interface + interceptor.
### Add a new exception type
Inherit `CustomException`, pass the right status code in the base constructor. Don't bypass the handler — keep the handler's RFC 9457 mapping as the single source of truth for HTTP error shape.
## Gotchas
- **`AddDomainEvent` is `protected`** on `BaseEntity`. Domain events must be raised from inside the aggregate, not from a handler. That's by design: it keeps invariants and event emission co-located.
- **`DomainEvent.Create(factory)`** is the static helper that fills in `EventId` and `OccurredOnUtc` automatically. Use it; don't construct events manually with `Guid.NewGuid()` scattered through code.
- **Inner exceptions never reach the response.** `CustomException` accepts an inner exception for logging/diagnostics, but the global handler only maps the message, `ErrorMessages`, and status code into ProblemDetails — keep the public message clean and let logs carry the cause.
## Critical files
- `src/BuildingBlocks/Core/Domain/BaseEntity.cs`
- `src/BuildingBlocks/Core/Domain/AggregateRoot.cs`
- `src/BuildingBlocks/Core/Domain/DomainEvent.cs`
- `src/BuildingBlocks/Core/Context/ICurrentUser.cs`
- `src/BuildingBlocks/Core/Exceptions/CustomException.cs`
## Related
- [Persistence](/docs/building-blocks/persistence/) — `BaseDbContext` and the interceptors that consume these interfaces.
- [Web](/docs/building-blocks/web/) — `CurrentUserMiddleware` populates `ICurrentUser`.
- [Architecture overview](/docs/architecture/) — modular monolith + Vertical Slice patterns.
---
# Eventing building block
> Event bus implementation (InMemory or RabbitMQ), outbox/inbox stores for durable delivery, and the open-generic handler registration that lets modules subscribe with one method.
Source: https://fullstackhero.net/docs/building-blocks/eventing/
The Eventing block is the runtime side of the kit's integration-event story. It ships two `IEventBus` implementations — `InMemoryEventBus` (synchronous, in-process) and `RabbitMqEventBus` (durable, cross-service) — an EF Core-backed outbox store for transactional delivery, an inbox store for receiver-side idempotency, a `JsonEventSerializer`, and the auto-discovery hook that registers every `IIntegrationEventHandler` in your module assemblies.
Publish goes through the outbox in the same transaction as your business write. A background dispatcher hands it to RabbitMQ. The receiver's inbox dedupes by event id, so if the broker redelivers (network blip, consumer crash) the handler doesn't fire twice. Two stores, one obvious pattern.
## What it ships
### Extensions
- **`AddEventingCore(services, configuration)`** — registers `JsonEventSerializer`, a no-op `IEventTenantScope` default (the Multitenancy module swaps in a Finbuckle-backed one), and picks the `IEventBus` from `EventingOptions:Provider` (`InMemory` default, `RabbitMQ`). Adds the outbox dispatcher hosted service when `UseHostedServiceDispatcher` is true.
- **`AddEventingForDbContext(services)`** — registers `EfCoreOutboxStore` + `EfCoreInboxStore` (scoped) plus the `OutboxDispatcher` scoped service.
- **`AddIntegrationEventHandlers(services, assemblies[])`** — scans the supplied assemblies for `IIntegrationEventHandler` implementations and registers them scoped in DI.
### Event bus implementations
- **`InMemoryEventBus`** — in-process. For each event it **sets the tenant context first** (`IEventTenantScope.Begin(event.TenantId)`), creates a fresh DI scope, resolves the matching handlers, checks the inbox (skip if already processed by that handler), awaits each handler in order, then marks the inbox row. Great for dev/test and single-process production hosts.
- **`RabbitMqEventBus`** — durable, cross-service; uses `RabbitMQ.Client` and publishes to a durable topic exchange (`EventingOptions:RabbitMQ`).
### Outbox / inbox
- **`IOutboxStore`** (`AddAsync`, `GetPendingBatchAsync`, `MarkAsProcessedAsync`, `MarkAsFailedAsync`) + `EfCoreOutboxStore` — serialize integration events into an `OutboxMessages` table in your module's DbContext. `AddAsync` rides the same DbContext as your business write, so inside an open transaction the event commits atomically with it.
- **`IInboxStore`** + `EfCoreInboxStore` — dedupe table keyed by **(event id, handler name)**, so each handler processes an event at most once.
- **`OutboxDispatcher`** — scoped service; reads pending rows in batches (`OutboxBatchSize`, default 100), deserializes, publishes via `IEventBus`, marks processed. A failing row increments `RetryCount`; after `OutboxMaxRetries` (default 5) it's flagged `IsDead` and skipped thereafter.
- **`OutboxDispatcherHostedService`** — background loop calling the dispatcher every `OutboxDispatchIntervalSeconds` (default 10).
- **`OutboxMessage`** — `Id`, `CreatedOnUtc`, `Type`, `Payload`, `TenantId`, `CorrelationId`, `ProcessedOnUtc`, `RetryCount`, `LastError`, `IsDead`. Implements `IGlobalEntity` (background processors must scan across tenants).
- **`InboxMessage`** — `Id` + `HandlerName` (composite key), `EventType`, `ProcessedOnUtc`, `TenantId`. Also `IGlobalEntity`.
### Serializer
- **`JsonEventSerializer`** — System.Text.Json; the outbox stores the event's type name alongside the payload so the dispatcher can rehydrate the concrete event for publishing.
## How modules consume Eventing
Register against your DbContext during module startup:
```csharp
public void ConfigureServices(IHostApplicationBuilder builder)
{
builder.Services.AddHeroDbContext();
builder.Services.AddEventingForDbContext();
}
```
There are two publish paths, and the kit uses both:
**Durable (outbox)** — write the event to `IOutboxStore` next to your business change; the dispatcher publishes it later. This is what Identity does for `UserRegisteredIntegrationEvent`:
```csharp
public async ValueTask Handle(ResolveTicketCommand cmd, CancellationToken ct)
{
var ticket = await _db.Tickets.FindAsync([cmd.TicketId], ct).ConfigureAwait(false);
ticket!.Resolve(cmd.ResolutionNote);
await _db.SaveChangesAsync(ct).ConfigureAwait(false);
await _outbox.AddAsync(new TicketResolvedIntegrationEvent(/* … */), ct).ConfigureAwait(false);
return Unit.Value;
}
```
**Immediate** — call `IEventBus.PublishAsync` directly and handlers run right away (in-process with the InMemory bus). Chat's mention events and the tenant-lifecycle events go this way; you trade durability for latency.
The `OutboxDispatcher` picks up pending rows on the next interval (or you can call `DispatchAsync` from a Hangfire job if you don't want a hosted-service loop). The receiving side declares a handler:
```csharp
public sealed class TicketResolvedNotifyHandler(/* … */)
: IIntegrationEventHandler
{
public async Task HandleAsync(TicketResolvedIntegrationEvent evt, CancellationToken ct = default)
{
// write a notification, send an email, etc.
}
}
```
Host registers handlers in bulk via `AddIntegrationEventHandlers` against all module marker assemblies:
```csharp
builder.Services.AddIntegrationEventHandlers(moduleAssemblies);
```
## Configuration
```jsonc
{
"EventingOptions": {
"Provider": "InMemory", // or "RabbitMQ"
"OutboxBatchSize": 100,
"OutboxMaxRetries": 5,
"EnableInbox": true,
"OutboxDispatchIntervalSeconds": 10,
"UseHostedServiceDispatcher": true,
"RabbitMQ": {
"Host": "rabbitmq",
"Port": 5672,
"UserName": "guest",
"Password": "guest",
"VirtualHost": "/",
"ExchangeName": "fsh.events",
"QueuePrefix": "fsh",
"UseSsl": false,
"PublishRetryCount": 3,
"PublishRetryDelayMs": 1000
}
}
}
```
Set `UseHostedServiceDispatcher = false` when you'd rather drive the dispatcher from Hangfire on a fixed schedule (more deterministic for some ops setups).
## How to extend
### Add another transport
Implement `IEventBus`; register your implementation in place of `InMemoryEventBus` / `RabbitMqEventBus`. Bus consumers don't care which one is wired.
### Add a side-channel like Outbox-to-Kafka
`OutboxDispatcher` is small and replaceable. Subclass or wrap it to publish to Kafka in addition to RabbitMQ, or to write to multiple destinations.
### Skip the outbox for cheap fire-and-forget
The outbox is opt-in: `IEventBus.PublishAsync` already goes straight to the bus. If an event doesn't need transactional durability (a cache invalidation hint, a metrics ping), just publish it directly and skip `IOutboxStore` — that's exactly what Chat and the tenant-lifecycle events do.
## Gotchas
- **Domain events vs integration events** are different things in the kit. Domain events fire **inside the SaveChanges interceptor** (synchronous, in-module, via Mediator). Integration events go through the outbox and `IEventBus` (asynchronous, cross-module / cross-service). Use the right one — domain events for invariants and bookkeeping inside the module, integration events for everything else.
- **Outbox dispatcher is scoped per cycle.** Each poll gets a fresh DbContext scope; a failing publish increments the row's `RetryCount` (with `LastError` recorded) and is retried on the next interval. At `OutboxMaxRetries` the row is flagged `IsDead` and skipped from then on — monitor/clean dead rows, they don't retry themselves.
- **Inbox dedupes per handler, by `(EventId, HandlerName)`.** A redelivered event is skipped only for handlers that already completed it; if you mint two events with the same `Id`, the second is silently dropped for every handler. Always generate a fresh `Guid` per event.
- **Background publishers need the tenant context set.** `InMemoryEventBus` does this for you via `IEventTenantScope` (reading `event.TenantId`) *before* resolving handlers — a `MultiTenantDbContext` captures its tenant at construction, so setting it later is too late. If you build your own bus or dispatch path, preserve this ordering or tenant-filtered handlers NRE.
- **RabbitMQ publishing retries in-process** (`PublishRetryCount` / `PublishRetryDelayMs`), but consumers own their durability via inbox + retry semantics.
## Critical files
- `src/BuildingBlocks/Eventing/ServiceCollectionExtensions.cs`
- `src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs`
- `src/BuildingBlocks/Eventing/RabbitMq/RabbitMqEventBus.cs`
- `src/BuildingBlocks/Eventing/Outbox/OutboxDispatcher.cs`
- `src/BuildingBlocks/Eventing/Inbox/EfCoreInboxStore.cs`
## Related
- [Eventing.Abstractions](/docs/building-blocks/eventing-abstractions/) — the dependency-free contracts.
- [Notifications module](/docs/modules/notifications/) — consumes integration events into inbox rows.
- [Webhooks module](/docs/modules/webhooks/) — uses an open-generic handler to fan all events to HTTP subscribers.
---
# Eventing.Abstractions building block
> Dependency-free contracts for cross-module and cross-service events — IIntegrationEvent, IIntegrationEventHandler, IEventBus, IEventSerializer.
Source: https://fullstackhero.net/docs/building-blocks/eventing-abstractions/
The Eventing.Abstractions block exists for one reason: **contracts assemblies should not pull in EF Core, RabbitMQ, or the Eventing runtime when they declare an event.** This block has zero NuGet dependencies — just five interfaces. It's the smallest building block in the kit.
A module's `*.Contracts` assembly references **only** `Eventing.Abstractions`. The module's runtime assembly references both this block and the heavier `Eventing` block (which carries the EF Core / RabbitMQ implementations). That keeps cross-module dependencies one-way, contract-only, and lightweight.
## What it ships
Five interfaces. That's it.
```csharp
// IIntegrationEvent.cs
public interface IIntegrationEvent
{
Guid Id { get; }
DateTime OccurredOnUtc { get; }
string? TenantId { get; } // null for global, non-tenant-scoped events
string CorrelationId { get; } // ties events to requests and traces
string Source { get; } // e.g. "Modules.Identity"
}
// IIntegrationEventHandler.cs
public interface IIntegrationEventHandler where TEvent : IIntegrationEvent
{
Task HandleAsync(TEvent @event, CancellationToken ct = default);
}
// IEventBus.cs
public interface IEventBus
{
Task PublishAsync(IIntegrationEvent @event, CancellationToken ct = default);
Task PublishAsync(IEnumerable events, CancellationToken ct = default);
}
// IEventSerializer.cs
public interface IEventSerializer
{
string Serialize(IIntegrationEvent @event);
IIntegrationEvent? Deserialize(string payload, string eventTypeName);
}
// IEventTenantScope.cs — ambient tenant context for event dispatch
public interface IEventTenantScope
{
IDisposable Begin(string? tenantId);
}
```
`IEventTenantScope` is the piece that makes background publishing safe in a multi-tenant world: the bus calls `Begin(event.TenantId)` **before** resolving handlers, because a `MultiTenantDbContext` captures its tenant at construction time. The default implementation is a no-op; the Multitenancy module registers a Finbuckle-backed one.
## Pattern: where each interface lives
```
src/Modules/Notifications/
├── Modules.Notifications.Contracts/
│ └── (no references except Eventing.Abstractions + Core)
└── Modules.Notifications/
└── (references Eventing — gets InMemoryEventBus, OutboxDispatcher, etc.)
src/Modules/Chat/
├── Modules.Chat.Contracts/
│ ├── IntegrationEvents/MentionedInChannelIntegrationEvent.cs
│ │ └── implements IIntegrationEvent (Eventing.Abstractions)
│ └── (no reference to Modules.Notifications.Contracts)
└── Modules.Chat/
└── publishes via IEventBus (Eventing.Abstractions)
```
When Chat publishes a `MentionedInChannelIntegrationEvent`, Notifications consumes it through `IIntegrationEventHandler`. Neither runtime references the other; both just reference the event type declared in `Modules.Chat.Contracts`. The Eventing runtime (and your event bus of choice) wires the publish-to-subscribe path at runtime.
## How modules consume the contracts
Define a custom event in your contracts assembly — this is Chat's real mention event:
```csharp
// src/Modules/Chat/Modules.Chat.Contracts/Events/MentionedInChannelIntegrationEvent.cs
public sealed record MentionedInChannelIntegrationEvent(
Guid Id,
DateTime OccurredOnUtc,
string? TenantId,
string CorrelationId,
string Source,
Guid ChannelId,
string? ChannelName,
Guid MessageId,
string AuthorUserId,
string MentionedUserId,
string BodyPreview) : IIntegrationEvent;
```
Publish from a handler in the runtime module:
```csharp
await eventBus.PublishAsync(new MentionedInChannelIntegrationEvent(
Id: Guid.NewGuid(),
OccurredOnUtc: DateTime.UtcNow,
TenantId: currentUser.GetTenant(),
CorrelationId: correlationId,
Source: "Modules.Chat",
/* …payload fields… */), ct).ConfigureAwait(false);
```
Consume from another module's runtime project:
```csharp
public sealed class MentionedInChannelNotifyHandler(/* deps */)
: IIntegrationEventHandler
{
public Task HandleAsync(MentionedInChannelIntegrationEvent evt, CancellationToken ct = default)
{
// ...
}
}
```
## Gotchas
- **No base class.** `IIntegrationEvent` is a pure interface. Use sealed records to implement it; do not invent a base class in this block — it would force every contracts assembly to pull in that base type's transitive deps.
- **`Source` is a free-form string.** Use the canonical module name (`Modules.Tickets`, `Modules.Chat`) so logs and diagnostics stay greppable.
- **`TenantId` is a nullable string.** Global, non-tenant-scoped events carry a null `TenantId`; consumers that key on tenant must guard (the Webhooks fanout handler skips null-tenant events entirely). The tenant id also drives `IEventTenantScope` — a null id leaves the ambient tenant context unchanged.
- **`CorrelationId` is a non-nullable string.** It exists to tie events back to the request/trace that caused them — propagate the incoming correlation id, don't invent a new one mid-chain.
- **Handler registration is open-generic friendly.** The Webhooks module registers `WebhookFanoutHandler` against the open `IIntegrationEventHandler<>`, so one handler type covers every event. If you do this, be deliberate about the side effects.
## Critical files
- `src/BuildingBlocks/Eventing.Abstractions/IIntegrationEvent.cs`
- `src/BuildingBlocks/Eventing.Abstractions/IIntegrationEventHandler.cs`
- `src/BuildingBlocks/Eventing.Abstractions/IEventBus.cs`
- `src/BuildingBlocks/Eventing.Abstractions/IEventSerializer.cs`
- `src/BuildingBlocks/Eventing.Abstractions/IEventTenantScope.cs`
## Related
- [Eventing](/docs/building-blocks/eventing/) — the implementation block with `InMemoryEventBus`, `RabbitMqEventBus`, outbox/inbox stores.
- [Webhooks module](/docs/modules/webhooks/) — uses the open-generic `IIntegrationEventHandler<>` pattern to fan every event to subscribers.
- [Notifications module](/docs/modules/notifications/) — consumes integration events into per-user inbox rows.
---
# Jobs building block
> Hangfire 1.8 wiring with multi-tenant context preservation, DI-aware activator, basic-auth dashboard, telemetry filter, and stale-lock cleanup.
Source: https://fullstackhero.net/docs/building-blocks/jobs/
The Jobs block wires Hangfire 1.8 into the kit. The interesting parts are the **tenant-aware job filter** (preserves multi-tenant context across enqueue → execute), the **DI-aware activator** (jobs get scoped service resolution), the **OpenTelemetry filter** (activities for every job), the **basic-auth dashboard** at `/jobs`, and a **stale-lock cleanup service** for after-crash recovery.
`FshJobFilter` captures the resolved tenant and the authenticated user id as job parameters at enqueue time; `FshJobActivator` restores both into the job's fresh DI scope before the body runs. You enqueue `jobs.Enqueue(j => j.RunAsync(...))` from a request and the job sees the same tenant context the request had.
## What it ships
### Extensions
- **`AddHeroJobs(services)`** — registers the Hangfire server with 5 workers, 30-second heartbeat, queues `default` + `email`, and 30-second polling. Reads `DatabaseOptions:Provider` to select Postgres (`Hangfire.PostgreSql`) or SQL Server storage. Registers `FshJobActivator`, `FshJobFilter`, `LogJobFilter`, `HangfireTelemetryFilter`, plus `HangfireStaleLockCleanupService`.
- **`UseHeroJobDashboard(app, configuration)`** — wires the Hangfire dashboard at the route configured in `HangfireOptions:Route` (default `/jobs`) behind a basic-auth filter using `HangfireOptions:UserName` + `HangfireOptions:Password`.
### Service interface
- **`IJobService`** — convenience facade over Hangfire's static `BackgroundJob` API: `Enqueue` / `Enqueue` (with an optional queue overload), `Schedule` / `Schedule` (by `TimeSpan` delay or `DateTimeOffset`), `Delete`, `Requeue`. All return/accept Hangfire job-id strings. Recurring jobs go through Hangfire's `IRecurringJobManager` directly.
- **`HangfireService : IJobService`** — implementation.
### Filters
- **`FshJobActivator : JobActivator`** — resolves job classes from a fresh `IServiceScope` per job, restoring the captured tenant (via Finbuckle's `IMultiTenantContextSetter`) and user id (via `ICurrentUserInitializer`) before resolution — so the job's scoped DbContext is built with the right tenant.
- **`FshJobFilter : IClientFilter`** — at enqueue time, stamps the current tenant + user id onto the job as parameters (skipped when there's no HTTP context, e.g. recurring-job creation).
- **`LogJobFilter`** — logs every state transition (Enqueued, Processing, Succeeded, Failed) through `ILogger`.
- **`HangfireTelemetryFilter`** — opens an OpenTelemetry activity per job with `job.name`, `job.queue`, `job.id` tags.
- **`HangfireCustomBasicAuthenticationFilter`** — gates the dashboard via basic auth.
### Hosted service
- **`HangfireStaleLockCleanupService`** — runs a few seconds after host startup, queries the storage for orphaned locks (held by a server that crashed), and releases them.
### Options
- **`HangfireOptions`** — `UserName` (required, min 3 chars), `Password` (required, min 12 chars — both validated at startup, no safe default), `Route` (default `/jobs`).
## How modules consume Jobs
Inject `IJobService` to enqueue:
```csharp
public sealed class SendInvoiceEmailCommandHandler(IJobService jobs)
: ICommandHandler
{
public ValueTask Handle(SendInvoiceEmailCommand cmd, CancellationToken ct)
{
jobs.Enqueue(j => j.RunAsync(cmd.InvoiceId, CancellationToken.None));
return ValueTask.FromResult(Unit.Value);
}
}
```
Or register a recurring job — the kit's pattern is to resolve `IRecurringJobManager` inside the module's `MapEndpoints` (it's null when jobs are disabled, so guard it):
```csharp
var jobManager = endpoints.ServiceProvider.GetService();
if (jobManager is not null)
{
// Fire at 00:05 UTC on the 1st of every month; the job bills the previous period.
jobManager.AddOrUpdate(
"billing-monthly-invoices",
Job.FromExpression(j => j.RunAsync(CancellationToken.None)),
"5 0 1 * *",
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
}
```
The Billing module ships `MonthlyInvoiceJob` (monthly); the Files module ships `PurgeOrphanedFilesJob` (hourly) and `PurgeDeletedFilesJob` (daily, 03:30 UTC); the Auditing module ships `AuditRetentionJob` (cron from `AuditRetentionOptions`, no-op until enabled).
## Configuration
```jsonc
{
"HangfireOptions": {
"UserName": "admin",
"Password": "set-via-secrets-min-12-chars",
"Route": "/jobs"
}
}
```
The storage provider falls out of `DatabaseOptions:Provider` — PostgreSQL routes to `Hangfire.PostgreSql`; MSSQL routes to the SQL Server storage.
## How to extend
### Add a new queue
Hangfire dispatches by queue name. Register your job with `[Queue("priority")]` and add the queue name to the server's queue array in `AddHeroJobs`:
```csharp
services.AddHangfireServer(opts => opts.Queues = new[] { "default", "email", "priority" });
```
### Decorate jobs with a custom filter
Implement `IServerFilter` and register it in DI; `AddHangfireServer` will pick it up. The kit's filters are templates — read `FshJobFilter.cs` for the lifecycle hooks (`OnPerforming`, `OnPerformed`).
### Use Hangfire's `[AutomaticRetry]` attribute
For per-job retry policies:
```csharp
[AutomaticRetry(Attempts = 4, OnAttemptsExceeded = AttemptsExceededAction.Fail,
DelaysInSeconds = new[] { 30, 120, 600, 3600 })]
public sealed class WebhookDispatchJob { /* ... */ }
```
The Webhooks module uses this exact pattern.
## Gotchas
- **Hangfire serializes arguments** when enqueuing. Pass primitives (Guid, string, int) — not large object graphs. If you need state, look it up inside the job from the DI-resolved DbContext.
- **`FshJobActivator` creates a new scope per job.** Don't hold scoped state across jobs; each invocation gets a fresh DbContext.
- **Dashboard auth is basic.** Use HTTPS in production. Consider IP whitelisting in your reverse proxy in addition.
- **Stale-lock cleanup runs after startup.** It defers a few seconds so it doesn't interfere with legitimate startup-time lock acquisition; it's best-effort and logs a warning with the count when it actually releases something.
## Critical files
- `src/BuildingBlocks/Jobs/Extensions.cs`
- `src/BuildingBlocks/Jobs/Services/HangfireService.cs`
- `src/BuildingBlocks/Jobs/FshJobFilter.cs`
- `src/BuildingBlocks/Jobs/FshJobActivator.cs`
- `src/BuildingBlocks/Jobs/HangfireStaleLockCleanupService.cs`
## Related
- [Web](/docs/building-blocks/web/) — `FshPlatformOptions.EnableJobs` toggle.
- [Billing module](/docs/modules/billing/) — `MonthlyInvoiceJob` recurring schedule.
- [Files module](/docs/modules/files/) — orphan + retention purge jobs.
- [Webhooks module](/docs/modules/webhooks/) — `[AutomaticRetry]` with exponential backoff.
---
# Mailing building block
> SMTP or SendGrid email abstraction behind a single IMailService, with multi-recipient delivery and HTML body support.
Source: https://fullstackhero.net/docs/building-blocks/mailing/
The Mailing block is the email abstraction the kit's transactional emails go through. Identity uses it for email confirmation, password reset, and welcome flows; any other module that needs to send mail does so via `IMailService`. Two implementations ship — SMTP via MailKit / MimeKit, and SendGrid via the official SDK — picked at startup from a single `UseSendGrid` flag.
The block sends mail; it does not template it. Build your HTML body in your handler (with Razor, Scriban, or a static string), then pass it to `SendAsync`. Keep templating out of the abstraction so you're free to swap engines without touching every call site.
## What it ships
### Extension
- **`AddHeroMailing(services)`** — binds `MailOptions`, registers one process-wide `ISendGridClient` singleton (per-send client construction leaks sockets), and a transient `IMailService` factory that returns `SendGridMailService` when `MailOptions:UseSendGrid` is `true`, otherwise `SmtpMailService`.
### Service interface
```csharp
public interface IMailService
{
Task SendAsync(MailRequest request, CancellationToken ct);
}
```
### Request model
`MailRequest` is a constructor-built class — recipients always come as a `Collection`:
```csharp
public class MailRequest(
Collection to,
string subject,
string? body = null,
string? from = null,
string? displayName = null,
string? replyTo = null,
string? replyToName = null,
Collection? bcc = null,
Collection? cc = null,
IDictionary? attachmentData = null,
IDictionary? headers = null);
```
The body is treated as HTML by both implementations (MailKit's `BodyBuilder.HtmlBody`; SendGrid sends it as both plain-text and HTML content). `From`/`DisplayName` on the request override the configured defaults per send.
### Implementations
- **`SmtpMailService`** — uses MailKit + MimeKit. Reads `MailOptions:Smtp:*` for host, port, credentials; connects with STARTTLS per send.
- **`SendGridMailService`** — uses the SendGrid SDK via the shared `ISendGridClient`. One API call per `MailRequest`; the first `To` address is the primary recipient, `Cc`/`Bcc`/`ReplyTo`/attachments map onto the SendGrid message.
### Options
- **`MailOptions`** — `From`, `DisplayName`, `UseSendGrid` (bool), `Smtp` (`Host` / `Port` / `UserName` / `Password`), `SendGrid` (`ApiKey` plus optional `From` / `DisplayName` overrides).
## How modules consume Mailing
Inject `IMailService` and call `SendAsync` — or better, do what Identity does and push the send onto the Hangfire `email` queue so a slow SMTP server never blocks the request:
```csharp
var mailRequest = new MailRequest(
new Collection { user.Email },
"Confirm Your Email Address",
emailBody);
jobService.Enqueue("email", () => mailService.SendAsync(mailRequest, cancellationToken));
```
Identity uses this shape for its email flows: email confirmation, password reset, and the welcome mail.
## Configuration
### SMTP
```jsonc
{
"MailOptions": {
"From": "no-reply@example.com",
"DisplayName": "fullstackhero",
"UseSendGrid": false,
"Smtp": {
"Host": "smtp.example.com",
"Port": 587,
"UserName": "smtp-user",
"Password": "set-via-secrets"
}
}
}
```
### SendGrid
```jsonc
{
"MailOptions": {
"From": "no-reply@example.com",
"DisplayName": "fullstackhero",
"UseSendGrid": true,
"SendGrid": {
"ApiKey": "set-via-secrets"
}
}
}
```
## How to extend
### Add an SES (or Mailgun, Postmark…) provider
Implement `IMailService` against the SDK of your choice and register it in place of the existing implementations. Both `SmtpMailService` and `SendGridMailService` are templates of "translate `MailRequest` to the provider's API."
### Add templating
Build a separate `IMailTemplateRenderer` service. Resolve a Razor or Scriban template, render to HTML, then call `IMailService.SendAsync`. Keep templating outside `IMailService` so you can swap providers without rewriting templates.
### Add a retry policy
`Microsoft.Extensions.Http.Resilience` (already a dep of the Web block) lets you wrap any `HttpClient` (SendGrid uses one) with a Polly v8 pipeline. For SMTP, wrap `SmtpMailService` with a decorator that retries on transient failures (timeout, 4xx with `try-again` codes).
## Gotchas
- **`MailRequest.To` is always a `Collection`** — even for one recipient. Note the SendGrid path builds a *single* email to `To[0]` (extra recipients ride along only as Cc/Bcc); the SMTP path addresses everyone in `To`. Bulk lists of 100+ recipients should be split into multiple `SendAsync` calls so a single bad address doesn't fail the whole batch.
- **No built-in templating.** This is by design. If you need it, ship `IMailTemplateRenderer` next to your handlers.
- **`SmtpMailService` opens a fresh connection per send.** Heavy traffic? Switch to SendGrid or wrap with a pool. The kit doesn't ship a long-lived SMTP client.
- **SendGrid charges per API call.** Bulk personalisation via the SendGrid Personalisations API is faster and cheaper for newsletter-style use. The kit's wrapper is the transactional path; replace it for marketing.
- **Plaintext credentials in `appsettings.json`** are a footgun. Use environment variables, `user-secrets`, or your cloud secrets manager. Never check `MailOptions:Smtp:Password` into git.
## Critical files
- `src/BuildingBlocks/Mailing/Extensions.cs`
- `src/BuildingBlocks/Mailing/Services/IMailService.cs`
- `src/BuildingBlocks/Mailing/Services/SmtpMailService.cs`
- `src/BuildingBlocks/Mailing/Services/SendGridMailService.cs`
- `src/BuildingBlocks/Mailing/MailOptions.cs`
## Related
- [Web](/docs/building-blocks/web/) — `FshPlatformOptions.EnableMailing` toggle.
- [Identity module](/docs/modules/identity/) — the biggest consumer (confirmation, reset, welcome).
- [Notifications module](/docs/modules/notifications/) — in-app inbox; pair it with email for double-channel delivery.
---
# Persistence building block
> EF Core base context with auto-applied multitenancy and soft-delete filters, the Specification pattern, audit + domain-event interceptors, and per-tenant connection strings.
Source: https://fullstackhero.net/docs/building-blocks/persistence/
The Persistence block is the EF Core configuration layer every module sits on top of. It ships a `BaseDbContext` that auto-applies tenant isolation and the soft-delete query filter, the `Specification` pattern for composable queries, two `ISaveChangesInterceptor` implementations (audit + domain events), and a `DatabaseOptions` switch between PostgreSQL and SQL Server. Around **400-600 lines of code**.
Make your module's DbContext inherit `BaseDbContext`, call `base.OnModelCreating(builder)` **last** in your override, and every tenant-aware entity is filtered automatically. No per-entity opt-in.
## What it ships
### Extensions
- **`AddHeroDatabaseOptions(services, configuration)`** — binds `DatabaseOptions` with validate-on-start, registers `AuditableEntitySaveChangesInterceptor` + `DomainEventsInterceptor` as `ISaveChangesInterceptor`, `TimeProvider.System`, and a startup logger that prints the active provider.
- **`AddHeroDbContext(services)`** — registers a DbContext via `ConfigureHeroDatabase` (provider switching from `DatabaseOptions`) with every registered `ISaveChangesInterceptor` attached.
### Base type
- **`BaseDbContext`** — extends Finbuckle's `MultiTenantDbContext`. In `OnModelCreating` it appends the named `SoftDelete` query filter to every `ISoftDeletable` entity, then calls `ApplyTenantIsolationByDefault()` which marks **every** entity `IsMultiTenant()` unless it implements `IGlobalEntity` — tenant isolation is default-ON, opt-out only. In `SaveChangesAsync` it sets `TenantNotSetMode.Overwrite` so unsaved entities without an explicit tenant inherit the current one. It also honours a per-tenant `ConnectionString` from `AppTenantInfo` in `OnConfiguring`.
### Specification pattern
- **`Specification`** — composable query base with protected `Where()`, `Include()` / `Include(string)`, `OrderBy()` / `OrderByDescending()` / `ThenBy()` / `ThenByDescending()`, and `ApplySortingOverride()` (whitelist-mapped client sort expressions like `"Name,-CreatedOn"` — no reflection). `AsNoTracking` is on by default. Multi-`Where` combines via expression-tree AND.
- **`SpecificationOfTResult` (`Specification`)** — projection variant with a `Select` expression.
- **`SpecificationExtensions.ApplySpecification(...)`** — turns a spec (entity or projected) into an `IQueryable`.
### Interceptors
- **`AuditableEntitySaveChangesInterceptor`** — fills in `CreatedOnUtc`/`CreatedBy` (Added) and `LastModifiedOnUtc`/`LastModifiedBy` (Modified, including owned-entity changes) from `ICurrentUser` + `TimeProvider`. It also **converts hard deletes into soft deletes**: an `ISoftDeletable` entry in Deleted state is flipped to Modified with `IsDeleted`/`DeletedOnUtc`/`DeletedBy` set (and cascade-deleted owned references restored so the UPDATE doesn't null their columns).
- **`DomainEventsInterceptor`** — after `SaveChangesAsync` succeeds, collects events from tracked `IHasDomainEvents` entities (clearing each list), then publishes each through Mediator. Handler failures are logged, not rethrown — the save is already committed; use the outbox for guaranteed delivery.
### Model-builder helpers
- **`ApplyTenantIsolationByDefault(modelBuilder)`** — marks every keyed, non-owned entity that isn't `IGlobalEntity` (and isn't already explicitly marked) as `IsMultiTenant().AdjustUniqueIndexes()`. Must run after `ApplyConfigurationsFromAssembly` so unique indexes exist to widen.
- **`AppendGlobalQueryFilter(modelBuilder, filterName, expr)`** — registers a **named** filter on every entity implementing `TInterface`; named filters compose with Finbuckle's anonymous tenant filter via AND.
### Options
- **`DatabaseOptions`** (lives in `FSH.Framework.Shared`) — `Provider` (`POSTGRESQL` default or `MSSQL`, matched case-insensitively), `ConnectionString`, `MigrationsAssembly`. An empty connection string fails validation at startup.
## How modules consume it
```csharp
// Your module's DbContext (this is the real CatalogDbContext)
public sealed class CatalogDbContext : BaseDbContext
{
public const string Schema = "catalog";
public CatalogDbContext(
IMultiTenantContextAccessor multiTenantContextAccessor,
DbContextOptions options,
IOptions settings,
IHostEnvironment environment) : base(multiTenantContextAccessor, options, settings, environment) { }
public DbSet Brands => Set();
public DbSet Products => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(Schema);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly);
base.OnModelCreating(modelBuilder); // CRITICAL — must be last
}
}
// Register in the module's ConfigureServices
builder.Services.AddHeroDbContext();
```
Anywhere you need a tenant-scoped query, just `await db.Products.ToListAsync(ct)` — the filter is applied transparently.
For composable queries:
```csharp
public sealed class ActiveProductsByBrandSpec : Specification
{
public ActiveProductsByBrandSpec(Guid brandId)
{
Where(p => p.BrandId == brandId && p.IsActive);
Include(p => p.Images);
OrderByDescending(p => p.CreatedAtUtc);
}
}
// Handler
var products = await _db.Products
.ApplySpecification(new ActiveProductsByBrandSpec(brandId))
.ToListAsync(ct).ConfigureAwait(false);
```
## Configuration
```jsonc
{
"DatabaseOptions": {
"Provider": "PostgreSQL",
"ConnectionString": "Host=localhost;Port=5432;Database=fsh;Username=postgres;Password=postgres",
"MigrationsAssembly": "FSH.Starter.Migrations.PostgreSQL"
}
}
```
`OptionsBuilderExtensions.ConfigureHeroDatabase()` picks `UseNpgsql()` or `UseSqlServer()` from the `Provider` value. Tenant-specific connection strings, when set on `AppTenantInfo`, override the default at request time via Finbuckle.
## How to extend
### Add a new interceptor
```csharp
public sealed class FullTextSearchInterceptor : SaveChangesInterceptor
{
public override ValueTask> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult result, CancellationToken ct = default)
{
// rewrite a generated tsvector column based on Body, e.g.
return base.SavingChangesAsync(eventData, result, ct);
}
}
// register
services.AddScoped();
```
Then attach it to whichever `DbContext` should run it via the `AddDbContext` options.
### Add a named query filter
```csharp
modelBuilder.Entity().HasQueryFilter("ActiveOnly", e => e.IsActive);
// then to bypass just that filter at a call site:
db.MyEntities.IgnoreQueryFilters(["ActiveOnly"])
```
The kit already uses this pattern for soft delete: the filter is registered under the stable name `QueryFilters.SoftDelete`, so trash views and restore handlers call `IgnoreQueryFilters([QueryFilters.SoftDelete])` and the tenant filter stays in force. Bare `IgnoreQueryFilters()` strips **everything** — including tenant isolation — so reserve it for root-gated cross-tenant queries and re-filter explicitly.
## Gotchas
- **`base.OnModelCreating(builder)` must come last** in your subclass's override. The base applies the global tenant filter + soft-delete filter; if you run it first your per-entity configurations can stomp on those filters (or vice versa).
- **`Specification.AsNoTracking` is `true` by default.** This is a deliberate guard against unbounded tracking of large query results. Call out tracked queries explicitly if you need write semantics.
- **Multi-`Where()` calls combine via AND**, not OR. The base doesn't compose `||`; if you need OR, write it inside a single expression.
- **`TenantNotSetMode.Overwrite`** in `SaveChangesAsync` means saving an entity without a tenant set will be silently filled in with the current tenant. That's intentional — without it, every handler would have to remember `entity.TenantId = current.GetTenant()` — but it does mean entities created in a null-tenant context get assigned to whichever tenant the current request belongs to. Don't run write paths from background threads without restoring tenant context first.
- **Entity ids are client-generated `Guid.CreateVersion7()`** (set in the aggregate's static factory), not database-generated. UUIDv7 keeps inserts index-friendly while letting the domain own the id.
- **Child entities reached only via a parent nav collection need `Property(x => x.Id).ValueGeneratedNever()`** in their configuration. Without it, EF sees the factory-assigned non-default `Guid` and tracks the child as Modified instead of Added — the insert silently never happens. `ProductImage`, `TicketComment`, and Chat's `MessageReaction`/`MessageMention` all carry this config for exactly that reason.
## Critical files
- `src/BuildingBlocks/Persistence/Context/BaseDbContext.cs`
- `src/BuildingBlocks/Persistence/Specifications/Specification.cs`
- `src/BuildingBlocks/Persistence/PersistenceExtensions.cs`
- `src/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs`
## Related
- [Core](/docs/building-blocks/core/) — `BaseEntity`, `IHasTenant`, `ISoftDeletable`, `IGlobalEntity`.
- [Multitenancy module](/docs/modules/multitenancy/) — the Finbuckle setup `BaseDbContext` builds on.
- [Architecture: multitenancy deep-dive](/docs/architecture/multitenancy-deep-dive/) — `IgnoreQueryFilters()` (all filters) vs `IgnoreQueryFilters([QueryFilters.SoftDelete])` (named filter only) discipline.
---
# Quota building block
> Per-tenant resource limits with Valkey or in-memory counters, gauge-based live usage, calendar-month windows, and HTTP middleware enforcement.
Source: https://fullstackhero.net/docs/building-blocks/quota/
The Quota block enforces per-tenant resource limits. Storage bytes used, number of active users, API calls per month, anything you care to meter. It ships Valkey-backed counters (Valkey is a Redis-compatible, BSD-licensed Redis fork) with an in-memory fallback (for dev/test only), plan-driven limits with per-tenant overrides, an `IQuotaGaugeProvider` hook for modules that expose live counts (the Identity module exposes user count this way), and a request middleware that returns 429 when a tenant blows past its plan.
Most tenants are on a plan. Plans live in `appsettings:QuotaOptions:Plans`. A tenant's `AppTenantInfo.QuotaLimits` is the per-tenant override dict — set values there only when you've negotiated a bespoke contract.
## What it ships
### Extensions
- **`AddHeroQuotas(services, configuration)`** — registers an `IQuotaService` based on `QuotaOptions:Enabled` and whether Valkey is configured. Disabled → `NoopQuotaService`. Enabled + Valkey → `RedisQuotaService`. Enabled + no Valkey → `InMemoryQuotaService` (per-process, dev/test only). Also registers `QuotaPlanResolver` + `QuotaEnforcementMiddleware`.
- **`UseHeroQuotas(app)`** — inserts the enforcement middleware into the pipeline (after authentication **and the rate limiter** — rate-limited requests shouldn't burn quota — before authorization).
### Service interfaces
- **`IQuotaService`** — `CheckAsync`, `RecordAsync`, `CheckAndRecordAsync`, `GetCurrentAsync` (all keyed by `string tenantId` + `QuotaResource`). The atomic check-and-record is the one you want for any "this counts towards the quota" path; `RecordAsync` accepts negative amounts for refunds.
- **`IQuotaGaugeProvider`** — extensibility hook for modules that expose live usage: a `Resource` property plus `GetCurrentAsync(tenantId, ct)`. Gauge-based resources (`Users`, `ActiveFeatureFlags`) are resolved on demand through these.
### Implementations
- **`RedisQuotaService`** — string counters keyed `quota:{tenantId}:{resource}:{YYYYMM}` for periodic resources (`ApiCalls`), `quota:{tenantId}:{resource}` for perpetual ones (`StorageBytes`, which accumulates until explicitly decremented). Periodic keys get a TTL aligned to the start of the next UTC month.
- **`InMemoryQuotaService`** — in-process counters. Single-instance only.
- **`NoopQuotaService`** — drop-in for when quotas are off; every check passes.
### Models
- **`QuotaResource`** enum — `ApiCalls`, `StorageBytes`, `Users`, `ActiveFeatureFlags`.
- **`QuotaCheckResult`** — `Allowed`, `Resource`, `CurrentUsage`, `Limit`, `ResetAtUtc` (null for non-periodic resources).
- **`QuotaPlanResolver`** — `ResolveLimit(tenant, resource)` returns the effective limit: the tenant's own `QuotaLimits` override wins, then the tenant's plan from `QuotaOptions:Plans`, then the `DefaultPlan`, then `long.MaxValue` (no limit). Negative values normalize to unlimited.
### Middleware
- **`QuotaEnforcementMiddleware`** — charges **one `ApiCalls` unit per request** via `CheckAndRecordAsync`. Skips health/metrics paths and requests with no resolved tenant. On exhaustion it returns `429 Too Many Requests` as RFC 9457 ProblemDetails with a `Retry-After` header and `resource`/`limit`/`currentUsage`/`resetAtUtc` extensions, and flags the request (`HttpContextItemKeys.QuotaRejected`) so the auditing module can tag it `OutOfQuota` without a hard dependency.
### Options
- **`QuotaOptions`** — `Enabled` (bool, default true), `Redis` (connection string), `DefaultPlan` (string; e.g. `free`), `Plans` (Dict — `Plans:free:StorageBytes = 1073741824`), `ExemptRootTenant` (bool, default true).
## How modules consume Quota
The Storage block wraps `IStorageService` with `QuotaMeteredStorageService`: every upload charges the tenant's `StorageBytes` meter (and a failed write rolls the charge back), every delete refunds the object's size. An exhausted storage quota surfaces as **`507 Insufficient Storage`**, not 429:
```csharp
var check = await _quotas
.CheckAndRecordAsync(tenantId, QuotaResource.StorageBytes, bytes, cancellationToken)
.ConfigureAwait(false);
if (!check.Allowed)
{
throw new CustomException(
$"Storage quota exceeded ({check.CurrentUsage}/{check.Limit} bytes).",
errors: null,
HttpStatusCode.InsufficientStorage);
}
```
The Identity module exposes the live user count as a gauge (`UserCountQuotaGaugeProvider`):
```csharp
internal sealed class UserCountQuotaGaugeProvider(UserManager userManager) : IQuotaGaugeProvider
{
public QuotaResource Resource => QuotaResource.Users;
public async ValueTask GetCurrentAsync(string tenantId, CancellationToken ct = default)
=> await userManager.Users
.IgnoreQueryFilters()
.CountAsync(u => EF.Property(u, "TenantId") == tenantId, ct)
.ConfigureAwait(false);
}
```
The quota service compares the gauge value against the plan limit on every `CheckAsync`.
## Configuration
```jsonc
{
"QuotaOptions": {
"Enabled": true,
"Redis": "localhost:6379",
"DefaultPlan": "free",
"ExemptRootTenant": true,
"Plans": {
"free": {
"ApiCalls": 100000,
"StorageBytes": 1073741824, // 1 GB
"Users": 5
},
"growth": {
"ApiCalls": 1000000,
"StorageBytes": 53687091200, // 50 GB
"Users": 50
}
}
}
}
```
Plan keys map to `QuotaResource` enum members; `-1` (or `long.MaxValue`) means unlimited. Per-tenant overrides go on `AppTenantInfo.QuotaLimits`. Anything you set there wins over the plan's default.
## How to extend
### Add a new gauge
```csharp
public sealed class OpenTicketsGaugeProvider(TicketsDbContext db) : IQuotaGaugeProvider
{
public QuotaResource Resource => QuotaResource.OpenTickets;
public async ValueTask GetCurrentAsync(string tenantId, CancellationToken ct = default)
=> await db.Tickets.LongCountAsync(t => t.Status != TicketStatus.Closed, ct).ConfigureAwait(false);
}
services.AddScoped();
```
…and add `OpenTickets` to the `QuotaResource` enum (in `FSH.Framework.Shared`) + the `Plans` dict in config. The Redis service resolves whichever provider matches the requested resource.
### Exempt a path from the per-request meter
The middleware's path exemptions (`/health*`, `/metrics`, …) are a hardcoded list in `QuotaEnforcementMiddleware.IsExempt` — there is no per-endpoint attribute today. To exempt more paths, extend that check; for non-`ApiCalls` resources nothing is enforced unless your code calls `CheckAndRecordAsync`.
### Use a different period
The monthly window is implemented inside `RedisQuotaService` (`BuildCounterKey` embeds `YYYYMM`; `GetPeriodResetUtc` returns the first moment of the next UTC month). A different window means a different `IQuotaService` implementation — the interface doesn't care about periods.
## Gotchas
- **Only `ApiCalls` is enforced automatically.** The middleware meters one unit per request; every other resource is enforced only where code calls into `IQuotaService` (storage does, via the metered decorator). The `Users` gauge reports usage — nothing blocks user creation against it out of the box.
- **Period is calendar-month, UTC — and only for `ApiCalls`.** Counters reset at midnight UTC on the 1st; cross-timezone customers see "your usage reset" at 4 PM PT on the 31st. `StorageBytes` never resets — it's a perpetual counter that uploads increment and deletes refund.
- **Gauges are read on-demand.** Each `CheckAsync` invokes the gauge providers, which usually do a DB query. Cache the results inside the gauge if the data is expensive to compute and slightly stale is fine.
- **`InMemoryQuotaService` is not cluster-safe.** Two instances of the API hold separate counters. Use Valkey (or any distributed cache) in production.
- **Root tenant exemption is global.** With `ExemptRootTenant = true`, the root/platform tenant has unlimited everything. Don't disable this in production unless you have a reason — internal admin actions tend to exceed all the limits you set for paying customers.
- **Valkey TTL.** Period keys carry a TTL aligned to the period end. Clock skew between instances can produce off-by-one errors at the period boundary. Use NTP.
## Critical files
- `src/BuildingBlocks/Quota/Extensions.cs`
- `src/BuildingBlocks/Quota/IQuotaService.cs`
- `src/BuildingBlocks/Quota/RedisQuotaService.cs`
- `src/BuildingBlocks/Quota/QuotaPlanResolver.cs`
- `src/BuildingBlocks/Quota/QuotaEnforcementMiddleware.cs`
## Related
- [Storage](/docs/building-blocks/storage/) — uses the metered decorator for `StorageBytes`.
- [Identity module](/docs/modules/identity/) — exposes the user-count gauge.
- [Billing module](/docs/modules/billing/) — plan keys here must match plan keys there.
---
# Shared building block
> Multi-tenancy types, permission registry, claim/role/action/resource constants, audit attributes, and shared DTOs used by every module.
Source: https://fullstackhero.net/docs/building-blocks/shared/
The Shared block carries the **typed constants and DTOs every module references** — tenant info, the permission registry, claim/action/resource string constants, audit attributes, and the `DatabaseOptions` consumed by Persistence. It exists so modules can reference one block of shared types instead of either copy-pasting constants or pulling in the heavier Web / Persistence runtime blocks.
New permission? Add it to `PermissionConstants`. New claim type? Add it to `ClaimConstants`. New audit attribute? Add it to `AuditAttributes`. The Shared block is the canonical home for "things every module needs to agree on."
## What it ships
### Multitenancy
- **`AppTenantInfo`** — extends Finbuckle's `TenantInfo` + implements `IAppTenantInfo`. Carries `Id`, `Identifier`, `Name`, `ConnectionString`, `AdminEmail`, `IsActive`, `ValidUpto`, `Issuer`, `Plan`, and `QuotaLimits` (a `Dictionary` of per-tenant overrides). Methods: `AddValidity(months)`, `SetValidity(DateTime)` (**forward-only** — backdating throws), `Activate()` / `Deactivate()` (both throw for the root tenant). New tenants get a 1-month demo validity by default.
- **`IAppTenantInfo`** — contract for tenant info; the kit references the interface where it can, allowing custom subclasses if you ever need them.
- **`MultitenancyConstants`** — well-known values: the `Root` tenant (`Id = "root"`, name, admin email), the `tenant` resolution identifier, and the tenant schema name.
- **`ITenantInitialPasswordBuffer`** — singleton interface for buffering the operator-supplied tenant admin password between the create-tenant handler and the background seed step. Implementation lives in the Multitenancy module.
### Identity + permissions
- **`PermissionConstants`** — the central registry. Static `Register(IEnumerable)` is called by each module during `ConfigureServices` (duplicates by `Name` are skipped). Properties: `All`, `Root` (where `IsRoot`), `Admin` (everything non-root), `Basic` (where `IsBasic`).
- **`FshPermission(Description, Action, Resource, IsBasic = false, IsRoot = false)`** — immutable record. Computed `Name` is the canonical permission string: `Permissions.{Resource}.{Action}` (e.g. `Permissions.Users.Create`).
- **`PermissionConstants.RequiredPermissionPolicyName`** — the name of the authorization policy that evaluates `.RequirePermission()` metadata. The Identity module registers the policy **and sets it as both `DefaultPolicy` and `FallbackPolicy`** — that double assignment matters (see gotchas).
- **`SystemPermissions.All`** — platform permissions registered by `AddHeroPlatform` before any module runs.
- **`RoleConstants`** — well-known role names (`Admin`, `Basic`) plus `DefaultRoles` / `IsDefault(role)`.
- **`ClaimConstants`** — claim type names (`tenant`, `fullName`, `permission`, `image_url`, `ipAddress`, `exp`, and the impersonation actor claims `act_sub` / `act_tenant`).
- **`CustomClaims`** — legacy alias set covering the same core claim names.
- **`ResourceConstants`** — framework resource names (`Tenants`, `Users`, `Roles`, `UserRoles`, `RoleClaims`, `AuditTrails`, `Dashboard`, `Hangfire`). Modules define their own resource strings in their contracts (e.g. `Catalog.Products`).
- **`ActionConstants`** — action names (`View`, `Search`, `Create`, `Update`, `Delete`, `Export`, `Generate`, `Clean`, `UpgradeSubscription`).
### Claims + authorization
- **`ClaimsPrincipalExtensions`** — extracts user id, tenant, email, etc. from a `ClaimsPrincipal`. Used in middleware and handlers.
- **`RequiredPermissionAttribute`** — implements **`IRequiredPermissionMetadata`** (a `HashSet RequiredPermissions`). This interface is what the authorization handler keys off — it must exist exactly once, in `FSH.Framework.Shared.Identity.Authorization`.
- **`EndpointExtensions.RequirePermission(permission, params additionalPermissions)`** — the canonical fluent helper for minimal-API endpoints; it attaches a `RequiredPermissionAttribute` as endpoint metadata. Permissions are plain strings (modules expose them as `const string`s in their contracts).
### Persistence shared
- **`DatabaseOptions`** — defined here, consumed by the Persistence block. `Provider` (string), `ConnectionString` (string, validated required), `MigrationsAssembly` (string).
- **`DbProviders.PostgreSQL`** + **`DbProviders.MSSQL`** — provider name constants.
### Auditing markers
- **`AuditAttributes`** — `[AuditIgnore]` (exclude a property from audit diffs/payloads) and `[AuditSensitive(hash, redact)]` (mask or hash the value when serialized). The Auditing module reads these when capturing changes.
- **`HttpContextItemKeys`** — well-known `HttpContext.Items` keys building-block middleware uses to signal the audit pipeline without depending on it. Currently: `QuotaRejected` (set by the quota middleware on a 429).
### Storage + quota DTOs
- **`FileUploadRequest`**, **`PresignedUploadUrl`**, **`StoredObjectMetadata`** — the storage transfer types shared between the Storage block and the Files module.
- **`QuotaResource`** — enum (`ApiCalls`, `StorageBytes`, `Users`, `ActiveFeatureFlags`). New quota dimensions get added here.
## How modules consume Shared
A typical module declares string constants for endpoint wiring plus an `FshPermission` list for the registry — this is Catalog's real shape:
```csharp
// Modules.Catalog.Contracts/Authorization/CatalogPermissions.cs
public static class CatalogPermissions
{
public static class Products
{
public const string Resource = "Catalog.Products";
public const string View = $"Permissions.{Resource}.View";
public const string Create = $"Permissions.{Resource}.Create";
// ...
}
public static IReadOnlyList All { get; } =
[
new("View Products", ActionConstants.View, Products.Resource, IsBasic: true),
// ...
];
}
// CatalogModule.ConfigureServices
PermissionConstants.Register(CatalogPermissions.All);
```
Endpoints reference the string constants via the fluent helper:
```csharp
endpoints.MapPost("/products", handler)
.RequirePermission(CatalogPermissions.Products.Create);
```
## How to extend
### Add a new resource
Add a constant to `ResourceConstants`, define a `FshPermission` set for the resource in your module's `Contracts.Authorization`, register it during module startup. Done.
### Add a new well-known claim
Add a name to `ClaimConstants` (or `CustomClaims` for kit-specific values). Update `TokenService` to issue the claim, update `ClaimsPrincipalExtensions` to read it. The Identity module is the place for both changes.
### Add a new quota dimension
Extend `QuotaResource` with the new value, expose a gauge provider (see [Quota](/docs/building-blocks/quota/)), update plan config to set defaults.
## Gotchas
- **`PermissionConstants.Register` dedupes by `Name`.** Calling it twice with the same permission is a no-op. There is no way to **remove** a permission — once registered, it stays for the process lifetime.
- **`IRequiredPermissionMetadata` must never be duplicated.** The authorization handler discovers permissions via this interface; a copy-pasted duplicate in another namespace means endpoint metadata implements the *wrong* interface and **every** `.RequirePermission()` gate silently stops enforcing.
- **The permission policy must stay both DefaultPolicy AND FallbackPolicy.** A group-level `.RequireAuthorization()` attaches the *default* policy, which suppresses the fallback — if the default were only "authenticated", `.RequirePermission()` would silently fail open (this was a real bug, fixed in PR #1290). The Identity module assigns the `RequiredPermission` policy to both; don't undo that.
- **`AppTenantInfo.SetValidity` is forward-only.** It throws "Subscription cannot be backdated" — for initial/explicit/backdated sets, assign `ValidUpto` directly.
- **`AppTenantInfo.Plan` is nullable.** When null, plan resolution falls back to `QuotaOptions:DefaultPlan` (`free` by default). Set this explicitly on every tenant unless you want the default.
- **`AppTenantInfo.QuotaLimits` is a per-tenant override.** If a resource is present in the dict, it wins over the plan's limit. Don't sprinkle overrides; reserve them for negotiated contracts.
- **Permissions are strings.** No compile-time safety on typos. Reference the module's constants — never write `"Permissions.X.Y"` literals in handlers or endpoints.
## Critical files
- `src/BuildingBlocks/Shared/Multitenancy/AppTenantInfo.cs`
- `src/BuildingBlocks/Shared/Identity/PermissionConstants.cs` (also home of the `FshPermission` record)
- `src/BuildingBlocks/Shared/Identity/SystemPermissions.cs`
- `src/BuildingBlocks/Shared/Identity/Authorization/RequiredPermissionAttribute.cs` (and `IRequiredPermissionMetadata`)
- `src/BuildingBlocks/Shared/Auditing/AuditAttributes.cs`
## Related
- [Core](/docs/building-blocks/core/) — even more foundational.
- [Web](/docs/building-blocks/web/) — `.RequirePermission()` applied to endpoints.
- [Quota](/docs/building-blocks/quota/) — `QuotaResource` enum lives here, the enforcement runs there.
- [Identity module](/docs/modules/identity/) — registers `IdentityPermissions.All` against `PermissionConstants`.
---
# Storage building block
> File storage abstraction — local filesystem or S3-compatible (MinIO / AWS S3) — with presigned URLs, tenant isolation, and optional quota metering.
Source: https://fullstackhero.net/docs/building-blocks/storage/
The Storage block is the kit's blob-storage abstraction. One interface (`IStorageService`), two implementations — `LocalStorageService` (filesystem) and `S3StorageService` (AWS S3 + MinIO compatible) — with presigned URL support and an optional `QuotaMeteredStorageService` decorator that meters per-tenant usage against the Quota block.
Clients PUT directly to S3 / MinIO using a short-lived presigned URL minted by your API. Your API never proxies bytes. Same on the read side — `GenerateDownloadUrlAsync` mints a presigned GET URL the browser hits directly.
## What it ships
### Extensions
- **`AddHeroLocalFileStorage(services)`** — registers `LocalStorageService` for `wwwroot`-based file I/O. Useful for dev.
- **`AddHeroStorage(services, configuration)`** — reads `Storage:Provider` **eagerly at registration time**. `"s3"` registers a shared `IAmazonS3` client + `S3StorageService`; anything else falls back to `LocalStorageService`. Wraps the chosen provider with `QuotaMeteredStorageService` when `QuotaOptions:Enabled` is true (also read at registration).
### Interface
```csharp
public interface IStorageService
{
// Returns the stored object's relative path/key
Task UploadAsync(FileUploadRequest request, FileType fileType,
CancellationToken cancellationToken = default) where T : class;
Task DownloadAsync(string path, CancellationToken cancellationToken = default);
Task ExistsAsync(string path, CancellationToken cancellationToken = default);
Task GetSizeAsync(string path, CancellationToken cancellationToken = default); // 0 if missing
Task RemoveAsync(string path, CancellationToken cancellationToken = default);
Task GenerateUploadUrlAsync(
string storageKey, string contentType, long maxBytes, TimeSpan ttl,
CancellationToken cancellationToken = default);
Task GenerateDownloadUrlAsync(
string storageKey, TimeSpan ttl, string? responseContentDisposition = null,
CancellationToken cancellationToken = default);
Task HeadObjectAsync(string storageKey, CancellationToken cancellationToken = default);
string BuildPublicUrl(string storageKey); // durable non-expiring URL (or server-relative path on local)
}
```
### Implementations
- **`LocalStorageService`** — stores under `wwwroot/uploads/{owner-type}/{guid}_{sanitized-filename}` and validates extension + size against `FileTypeMetadata.GetRules(fileType)`. Presigning is a **dev-only token fallback**: `GenerateUploadUrlAsync` issues a `local://upload/{token}` URL backed by `LocalPresignTokenStore`; `GenerateDownloadUrlAsync` and `BuildPublicUrl` return server-relative `/uploads/...` paths (no signing needed).
- **`S3StorageService`** — uses `AWSSDK.S3` with a singleton `IAmazonS3` client. A custom `ServiceUrl` (MinIO etc.) switches to path-style addressing per `ForcePathStyle`; presigned PUT/GET URLs come from the SDK's request signer.
- **`QuotaMeteredStorageService`** — decorator. `CheckAndRecordAsync(tenantId, QuotaResource.StorageBytes, bytes, ct)` on upload (throws 507 when exceeded, rolls back the charge if the write fails); refunds the object's size on `RemoveAsync`. Requests with no resolved tenant pass through unmetered.
### Request / response
- **`FileUploadRequest`** — `FileName`, `ContentType`, `Data`.
- **`FileDownloadResponse`** — `Stream`, `ContentType`, `FileName`, `ContentLength?`.
- **`PresignedUploadUrl`** — `Url`, `RequiredHeaders` (headers the browser must send verbatim, e.g. Content-Type), `ExpiresAt`.
- **`StoredObjectMetadata`** — `SizeBytes`, `ContentType`, `LastModified`, `ETag` (from HEAD).
- **`FileType`** enum — `Image`, `Document`, `Pdf`; `FileTypeMetadata` maps each to an extension whitelist + max size.
### Options
- **`S3StorageOptions`** — `Bucket`, `Region`, `Prefix`, `PublicRead` (default true), `PublicBaseUrl` (for non-expiring public URLs), `ServiceUrl` (custom endpoint for MinIO etc.), `AccessKey` / `SecretKey` (leave empty to use the AWS SDK credential chain), `ForcePathStyle` (only applies when `ServiceUrl` is set).
## How modules consume Storage
The Files module is the canonical consumer: its request-upload-url endpoint mints presigned PUT URLs, the finalize handler verifies size + content type via `HeadObjectAsync` before a row leaves `PendingUpload`, and purge jobs clear orphans.
Direct usage (without the Files module) looks like:
```csharp
public sealed class UploadAvatarHandler(IStorageService storage, ICurrentUser current)
: ICommandHandler // returns the public URL
{
public async ValueTask Handle(UploadAvatarCommand cmd, CancellationToken ct)
{
var storageKey = await storage.UploadAsync(
new FileUploadRequest
{
FileName = $"{current.GetUserId()}-avatar.png",
ContentType = "image/png",
Data = cmd.Data,
},
FileType.Image,
ct).ConfigureAwait(false);
return storage.BuildPublicUrl(storageKey);
}
}
```
The generic `T` names the owning type — local storage uses it as the folder segment (`uploads/useravatar/...`).
## Configuration
```jsonc
{
"Storage": {
"Provider": "s3", // or "local"
"S3": {
"Bucket": "fsh-uploads",
"ServiceUrl": "http://minio:9000", // omit for AWS S3 default
"Region": "us-east-1",
"ForcePathStyle": true, // required for MinIO
"AccessKey": "minioadmin",
"SecretKey": "set-via-secrets",
"PublicBaseUrl": "https://cdn.example.com" // for non-expiring public URLs
}
}
}
```
## How to extend
### Add Azure Blob Storage
Implement `IStorageService` against the Azure SDK and register it in place of the S3 implementation:
```csharp
services.AddSingleton();
```
Wrap with the quota decorator if you want:
```csharp
services.Decorate();
```
(The kit doesn't ship Scrutor; copy its `Decorate` pattern or register manually with `IConfigureOptions`.)
### Use AWS IAM role for credentials
Omit `AccessKey` and `SecretKey` from `S3StorageOptions`; the AWS SDK falls through to the instance-profile / EKS-pod-identity / web-identity chain automatically.
### Scan files post-upload
The Files module ships an `IFileScanner` hook. If you're using `IStorageService` directly without the Files module, kick off the scan in the same handler that finalises the upload.
## Gotchas
- **MinIO needs `ForcePathStyle = true`.** Virtual-hosted-style addressing puts the bucket name in the subdomain, which MinIO can't service without DNS gymnastics. The option defaults to `false` and only takes effect when `ServiceUrl` is set — set it explicitly for MinIO and other self-hosted S3-compatible services.
- **Presigned URLs have a TTL.** Once it expires, the URL is dead. Use `BuildPublicUrl` for non-expiring public URLs (and a bucket policy that grants public-read on that prefix).
- **`QuotaMeteredStorageService` is scoped.** It depends on `IQuotaService` which is scoped per request. Don't resolve it from a singleton or a hosted service without creating a scope.
- **Local storage paths are not tenant-segmented.** Files land under `wwwroot/uploads/{owner-type}/`, and anything `BuildPublicUrl` points at is served statically without policy enforcement. Don't use `LocalStorageService` in multi-tenant production — it exists for dev and tests.
- **`AddHeroStorage` reads configuration eagerly.** The provider choice and the quota toggle are evaluated once, at registration. Integration tests that swap config after host build must re-register/rewire `IStorageService` post-registration — changing `Storage:Provider` later does nothing.
## Critical files
- `src/BuildingBlocks/Storage/Extensions.cs`
- `src/BuildingBlocks/Storage/Services/IStorageService.cs`
- `src/BuildingBlocks/Storage/Local/LocalStorageService.cs`
- `src/BuildingBlocks/Storage/S3/S3StorageService.cs`
- `src/BuildingBlocks/Storage/S3/S3StorageOptions.cs`
## Related
- [Files module](/docs/modules/files/) — the policy + lifecycle layer on top of this block.
- [Quota](/docs/building-blocks/quota/) — usage metering wrapper.
- [Catalog module](/docs/modules/catalog/) — product images flow through here.
---
# Web building block
> The composition root for the host — AddHeroPlatform / UseHeroPlatform, module loader, global exception handler, validation pipeline, CurrentUser middleware, and observability.
Source: https://fullstackhero.net/docs/building-blocks/web/
The Web block is the host-side composition layer that turns a `IHostApplicationBuilder` into a fullstackhero application. Two extension methods — `AddHeroPlatform` and `UseHeroPlatform` — wire every cross-cutting concern: logging, OpenTelemetry, exception handling, CORS, OpenAPI + Scalar, API versioning, authentication, authorization, validation pipeline, idempotency, feature flags, SSE, SignalR realtime, rate limiting, module loading, and current-user resolution. Roughly **2,000+ lines of code across 40+ files**.
Your composition root has two real lines — `builder.AddHeroPlatform(o => ...)` and `app.UseHeroPlatform(p => ...)`. Everything else flows from those. Options toggles (`EnableCaching`, `EnableJobs`, etc.) decide which optional blocks come along for the ride.
## What it ships
### Composition methods
- **`AddHeroPlatform(IHostApplicationBuilder, Action?)`** — registers all platform services. Hooks every optional block (caching, jobs, mailing, feature flags, idempotency, SSE, realtime, quotas) behind a boolean toggle in `FshPlatformOptions`.
- **`UseHeroPlatform(WebApplication, Action?)`** — wires the middleware pipeline in the right order; mounts modules' endpoints and middleware via the `ModuleLoader`.
### Module discovery + loading
- **`IModule`** interface — every module implements it:
- `ConfigureServices(IHostApplicationBuilder)` — DI registration phase
- `MapEndpoints(IEndpointRouteBuilder)` — endpoint registration phase
- `ConfigureMiddleware(IApplicationBuilder)` — middleware insertion phase (default no-op)
- **`[assembly: FshModule(typeof(XModule), order)]`** — assembly-level attribute; lower order runs first, ties break alphabetically.
- **`ModuleLoader`** — `AddModules(builder, assemblies)` reads the `FshModule` attributes off the passed assemblies (and registers their FluentValidation validators), instantiates each module, and runs `ConfigureServices` in order. `UseModuleMiddlewares()` and `MapModules()` later replay `ConfigureMiddleware` + `MapEndpoints`.
### Cross-cutting
- **`GlobalExceptionHandler : IExceptionHandler`** — converts exceptions into `ProblemDetails` (RFC 9457): `CustomException` and its subclasses map to their `StatusCode` with `ErrorMessages` in an `errors` extension; FluentValidation's `ValidationException` becomes a 400 with a per-property errors dictionary; `UnauthorizedAccessException` → 401, `KeyNotFoundException` → 404, `BadHttpRequestException` keeps its own status. Everything else is a generic 500 with the real message withheld (in every environment). Every response carries `traceId` + `correlationId` extensions.
- **`ValidationBehavior`** — Mediator pipeline behavior that runs FluentValidation against the request before the handler.
- **`CurrentUserMiddleware`** — registered last in the pipeline (after authorization) to populate `ICurrentUser` from the authenticated `ClaimsPrincipal` and tag the current OTel activity with user/tenant/correlation ids.
- **`MediatorTracingBehavior`** — emits OpenTelemetry activities for every Mediator command/query (registered by `AddHeroOpenTelemetry`).
### Feature toggles (`FshPlatformOptions`)
```csharp
builder.AddHeroPlatform(o =>
{
o.EnableCaching = true; // adds HybridCache + Valkey (default off)
o.EnableJobs = true; // adds Hangfire (default off)
o.EnableMailing = true; // adds IMailService (default off)
o.EnableQuotas = true; // adds quota services (default off)
o.EnableSse = true; // adds SSE plumbing (default off)
o.EnableRealtime = true; // adds SignalR + Redis backplane (default off)
o.EnableFeatureFlags = true; // adds Microsoft.FeatureManagement (default off)
// EnableCors, EnableOpenApi, EnableOpenTelemetry, EnableIdempotency default to true
});
```
`UseHeroPlatform` has its own `FshPipelineOptions` mirror: `UseCors`, `UseOpenApi`, `ServeStaticFiles`, `MapModules` (default true) and `MapSseEndpoints`, `MapRealtime`, `UseQuotas` (default false) — enabling a service in `AddHeroPlatform` is only half the job, you also flip the matching pipeline toggle.
### Sub-blocks the Web block wires
- **`AddHeroLogging`** — Serilog with HTTP context enrichers.
- **`AddHeroOpenTelemetry`** — traces + metrics via OTLP; registers `MediatorTracingBehavior` and wires the caching/Hangfire/module sources and meters.
- **`AddHeroOpenApi`** — OpenAPI documents + Scalar UI at `/scalar`; `OpenApiOptions` (Title, Description, Versions, Contact, License).
- **`AddHeroVersioning`** — `Asp.Versioning` with **URL-segment** versioning only (`api/v{version}/…`), default v1, assumed when unspecified.
- **`AddHeroIdempotency`** — `IdempotencyEndpointFilter` + `IdempotencyOptions` (`HeaderName` default `Idempotency-Key`, `DefaultTtl` 24h, `MaxKeyLength` 128); replay protection via distributed cache.
- **`AddHeroFeatureFlags`** — `Microsoft.FeatureManagement` with the `TenantFeatureFilter` for per-tenant overrides and a `FeatureGateEndpointFilter` for endpoints.
- **`AddHeroSse`** — Server-Sent Events plumbing (`SseConnectionManager`, token service, endpoints via `MapHeroSseEndpoints`).
- **`AddHeroRealtime`** — SignalR (`AppHub` at `/api/v1/realtime/hub` + presence endpoint) with a Redis backplane when `CachingOptions:Redis` is set.
- **`UseHeroSecurityHeaders`** — security-headers middleware driven by `SecurityHeadersOptions` (CSP, HSTS, X-Frame-Options, X-Content-Type-Options…).
- **`AddHeroResilience`** (on `IHttpClientBuilder`) — standard Polly resilience pipeline for outbound HTTP, tuned by `HttpResilienceOptions`.
- **`AddHeroRateLimiting` / `UseHeroRateLimiting`** — fixed-window policies from `RateLimitingOptions`.
- **`MapHeroHealthEndpoints`** — anonymous `/health/live` (process only) and `/health/ready` (all checks, 503 on failure), always mapped.
## How modules consume Web
A module's `IModule.ConfigureServices` adds its DI registrations; `MapEndpoints` mounts its routes:
```csharp
[assembly: FshModule(typeof(CatalogModule), 600)]
public sealed class CatalogModule : IModule
{
public void ConfigureServices(IHostApplicationBuilder builder)
{
PermissionConstants.Register(CatalogPermissions.All);
builder.Services.AddHeroDbContext();
// ... more registrations
}
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
var versionSet = endpoints.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1))
.ReportApiVersions()
.Build();
var group = endpoints
.MapGroup("api/v{version:apiVersion}/catalog")
.WithTags("Catalog")
.WithApiVersionSet(versionSet)
.RequireAuthorization();
group.MapCreateBrandEndpoint();
group.MapSearchBrandsEndpoint();
// ...
}
}
```
The `ModuleLoader` runs `ConfigureServices` for every module in the order declared by each module's assembly-level `[assembly: FshModule(typeof(XModule), order)]` attribute, then later runs `MapEndpoints` for each.
## The pipeline order
`UseHeroPlatform` wires the middleware pipeline in a specific order; this is the canonical sequence:
1. `UseExceptionHandler()` — converts exceptions to ProblemDetails
2. `UseResponseCompression()`
3. `UseHeroCors()` — **before** HTTPS redirect (preflight requests can't follow redirects)
4. `UseHttpsRedirection()`
5. `UseHeroSecurityHeaders()`
6. `UseStaticFiles()` (optional, `ServeStaticFiles`)
7. Hangfire dashboard (`/jobs` by default, `HangfireOptions:Route`)
8. `UseRouting()`
9. OpenAPI + Scalar
10. `UseAuthentication()`
11. Module-supplied middleware (via `ConfigureMiddleware` — e.g. Auditing's HTTP capture)
12. `UseHeroRateLimiting()`
13. `UseHeroQuotas()` (optional, `UseQuotas`)
14. `UseAuthorization()`
15. Module endpoint mapping (via `MapEndpoints`)
16. Health endpoints (always), SSE endpoints + SignalR (optional)
17. `CurrentUserMiddleware` (last so authorization is already resolved)
## Configuration
The Web block reads many config sections — `CorsOptions`, `OpenApiOptions`, `OriginOptions`, `SecurityHeadersOptions`, `RateLimitingOptions`, plus the optional sub-block sections (`CachingOptions`, `HangfireOptions`, `MailOptions`, `FeatureManagement`, `IdempotencyOptions`, `QuotaOptions`, `HttpResilienceOptions`). The kit's `appsettings.json` template ships a complete starter set.
## How to extend
### Add custom request logic without owning a module
`builder.Services.Configure(opts => opts.Filters.Add())` won't work for minimal APIs. Instead, add a Mediator pipeline behavior:
```csharp
public sealed class TimingBehavior(ILogger> log)
: IPipelineBehavior
{
public async ValueTask Handle(TRequest msg, MessageHandlerDelegate next, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
try { return await next(msg, ct).ConfigureAwait(false); }
finally { log.LogInformation("{Req} took {Ms}ms", typeof(TRequest).Name, sw.ElapsedMilliseconds); }
}
}
services.AddSingleton(typeof(IPipelineBehavior<,>), typeof(TimingBehavior<,>));
```
### Replace the global exception handler
Implement your own `IExceptionHandler`, register it as `services.AddExceptionHandler()`. Keep the RFC 9457 ProblemDetails shape — the kit's React clients parse it.
## Gotchas
- **CORS before HTTPS redirect.** Preflight `OPTIONS` requests cannot follow an HTTP→HTTPS redirect per the Fetch spec. The kit puts `UseCors` before `UseHttpsRedirection`. Don't reorder.
- **`CurrentUserMiddleware` runs last.** It's after authorization, so anything between `UseAuthentication` and `CurrentUserMiddleware` runs with a populated `ClaimsPrincipal` but a null `ICurrentUser`. If you write middleware that needs `ICurrentUser`, place it after this one — or read claims directly.
- **`AddHeroPlatform` defaults are conservative.** Caching, jobs, mailing, feature flags, SSE, realtime, quotas are all **off** unless explicitly enabled. `EnableCors`, `EnableOpenApi`, `EnableOpenTelemetry`, and `EnableIdempotency` default to on (CORS and OpenAPI are additionally gated by configuration — no `CorsOptions` origins means no CORS middleware; `OpenApiOptions:Enabled` can switch docs off per environment).
- **The `ModuleLoader` reads assembly attributes once at startup.** Adding a module means adding the project, adding the marker assembly to the host's `moduleAssemblies` array, **and** adding both marker types to `AddMediator(o => o.Assemblies = [...])` — in `Program.cs` *and* `DbMigrator/Program.cs` (four places total). Forgetting the Mediator step is the silent failure mode: services build, endpoints map, but handlers aren't found at request time.
## Critical files
- `src/BuildingBlocks/Web/Extensions.cs`
- `src/BuildingBlocks/Web/Modules/ModuleLoader.cs`
- `src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs`
- `src/BuildingBlocks/Web/Mediator/Behaviors/ValidationBehavior.cs`
- `src/BuildingBlocks/Web/Auth/CurrentUserMiddleware.cs`
## Related
- [Architecture: dependency injection](/docs/architecture/) — module load order + Mediator wiring.
- [Modules overview](/docs/modules/) — every module composes against this block.
- [Cross-cutting concerns](/docs/cross-cutting-concerns/) — feature flags, idempotency, OpenTelemetry.
---
# Overview
> Release notes and version history for fullstackhero.
Source: https://fullstackhero.net/docs/changelog/
Notable changes to the kit, newest first.
## 2026-07-13
- **Dashboard: tenants can now edit their own branding from Settings.** A new **Settings → Branding** tab lets a tenant admin holding `Tenants.UpdateTheme` customise their **light and dark palettes** and **brand asset URLs** (logo, dark-mode logo, favicon) with a live preview — mirroring the operator's existing tenant-branding card, but self-service and with no `tenant:` header, since the theme endpoints are already scoped to the current tenant. The tab renders only for holders of that permission; a direct-URL visit without it hits the API's `403`, surfaced as an error band. Editing is draft-based — a **Reset to defaults** action and per-palette reset are available, and unsaved edits are preserved while you work (a co-admin's concurrent change appears on a manual refresh rather than overwriting your form).
- **The `fsh` CLI and `dotnet new` template are now on NuGet as stable `10.0.0`.** The two distribution packages that 10.0.0 had been waiting on have shipped: `FullStackHero.CLI` (install with `dotnet tool install -g FullStackHero.CLI` — no more `--prerelease`) and `FullStackHero.NET.StarterKit` (`dotnet new install FullStackHero.NET.StarterKit`). Because `fsh new` scaffolds *from* that template, the one-command flow is now end-to-end: `dotnet tool install -g FullStackHero.CLI && fsh new MyApp` produces a fully renamed project — unique JWT signing key, generated Docker secrets, `npm install` run, initial commit on `main`. The [Install](/docs/getting-started/install/) and [CLI](/docs/cli/) pages now lead with the CLI as the recommended path; `git clone` and the GitHub template remain available for reading the source or zero-install runs. See the [10.0.0 release](https://github.com/fullstackhero/dotnet-starter-kit/releases/tag/10.0.0).
## 2026-06-12
- **Both apps: every nav surface is now permission-gated.** A tenant user whose role lacked a module's permission could still see and open its nav entry, landing on a guaranteed `403` — e.g. the dashboard Files page rendering a `ForbiddenException` error band for a user without `Files.Upload`. The dashboard sidebar now gates Chat, My Files, Subscription, Invoices, the Catalog pages, and Tickets on the same permission each page's API enforces (joining the already-gated Identity/Audits/Sessions/Trash entries), and the **command palette** — previously a second, fully ungated surface — applies the same gates to its Navigate *and* Create actions. In the admin console, the Webhooks nav entry and routes gained the `Webhooks.View` gate the API has required since the webhook permission hardening, and the Role editor's permission catalog now includes the **Webhooks** group so those permissions can actually be granted. Server-side enforcement is unchanged — this is the UX layer catching up.
- **Demo seed: the acme Manager role's catalog permissions now actually work (fix).** The seeded claims used permission names that don't exist (`Permissions.Brands.View` instead of the real `Permissions.Catalog.Brands.View`, and likewise for Categories/Products), so the "full catalog" Manager demo user silently had no catalog access at all. The seeder now references the module permission constants directly, so the names can't drift again. Existing databases: re-run the migrator's `seed-demo` verb to add the corrected claims (idempotent), and have demo users sign in again to pick them up.
## 2026-06-11
- **Identity: roles granted through groups now confer their permissions (fix).** A user group can carry role assignments, and the issued JWT already listed those group-derived roles as role claims — but the server-side permission resolver only counted roles assigned to the user *directly*. So a user whose only role came via a group failed every permission-gated endpoint with `403` even though their token showed the role, and their effective-permission list came back empty. The resolver now unions direct and group-derived roles, matching what token issuance and the permission-cache invalidation (which already fired on every group change) always assumed. No action required — the fix takes effect on each user's next permission-cache load.
## 2026-06-09
- **Dashboard: revoking an impersonation grant now ends the session gracefully instead of stranding the impersonated view.** When an operator revoked (or let expire) an in-progress impersonation from the admin console, the dashboard kept the now-dead impersonation token installed: every request `401`'d, but the page just rendered a stuck red error band under a half-loaded surface while the "End impersonation" banner lingered — a confusing, broken-looking state. The dashboard now detects this case (a `401` while the active token carries the `act_sub` impersonation claim — a durable signal that works even though production keeps the `401` body opaque) and routes to a calm full-screen **Impersonation ended** page that explains the access was revoked or expired and offers a single **Back to sign in** action, which clears the dead token. Mirrors the existing deactivated-tenant terminal page.
- **Dashboard: the Recycle bin only shows the sections you can restore.** The Trash page's five tabs (Products, Brands, Categories, Tickets, Files) were hard-coded with no permission check, so a user with rights to restore some resources but not others could click a tab and hit a `403` error band — e.g. a tenant member without Files access opening the Files tab. Each tab is now gated on the permission its endpoint enforces (the resource's `Restore`, or `Files.ViewTrash`), tabs the user can't reach are hidden, and the active tab falls back to the first visible one. The **Trash** sidebar entry itself hides when the user can reach none of them, and a direct visit shows a "no recycle bins available" state instead of a wall of `403`s. The server still enforces every permission — this is the UX layer catching up to the existing nav-gating pattern.
- **Realtime: the dashboard's live feed connects instantly instead of sitting on "Connecting" for up to 15s.** The SSE stream handler set its response headers and then waited for the first event or heartbeat before writing anything; Kestrel buffers response headers until the first body write, and the browser's `fetch()` only resolves once those headers arrive — so on an idle stream the System-status card showed "Connecting" for up to the 15s heartbeat interval on every connect and reconnect. The handler now flushes an initial no-op SSE comment immediately, so the client flips to "Connected" at once.
- **Template package: ships only tracked source — no more 583 MB package or leaked Terraform state (fix).** The `dotnet new fsh` template was packed by globbing the whole working tree (`..\**\*` with a denylist), which swept in any local artifact the denylist happened to miss. The result was a **583 MB** NuGet package that bundled `deploy/**/.terraform` provider binaries, **`*.tfstate` files (your infrastructure state — a secret leak)**, tens of MB of `audit-dlq` runtime dumps, and a stray local `release-nupkgs` output dir. Packing is now driven by a **git-tracked allowlist** — an MSBuild target enumerates `git ls-files` and ships only committed files — so nothing local or gitignored can ever leak into the package again. The package drops to **2.9 MB / 2057 files**, the `NU5123` long-path warnings are gone, and the scaffolded output is unchanged. This affects only the published template package, not what you get from a freshly scaffolded project.
## 2026-06-06
- **Realtime: a client disconnecting mid-connect no longer logs a spurious error.** When a SignalR connection dropped while `AppHub.OnConnectedAsync` was still wiring it up — a fast reconnect, a page navigation, or ordinary negotiate/connect churn — the aborting connection token cancelled the in-flight channel lookup, and the resulting `OperationCanceledException` surfaced as an `Error when dispatching 'OnConnectedAsync' on hub` log line with a full EF/Npgsql stack trace. Nothing was actually wrong: there was simply no connection left to set up. The hub now swallows cancellation caused by the connection aborting, so these benign disconnects stay out of the error log. Genuine faults still propagate and log as before.
## 2026-06-04
- **.NET Aspire updated to 13.4.0** — the Hosting packages (`Aspire.Hosting.JavaScript` / `PostgreSQL` / `Redis`) and the AppHost SDK move 13.3.5 → 13.4.0. `StackExchange.Redis` is bumped 2.11.0 → 2.13.17 because `Aspire.Hosting.Redis` 13.4.0 requires ≥ 2.13.1 (a `NU1109` downgrade otherwise). Builds clean with warnings-as-errors; the full suite (unit + Testcontainers integration, incl. the Valkey-backed tests) stays green.
- **Action required for existing local dev: wipe your Postgres data volume.** Aspire 13.4.0's default Postgres container image moves to **major 18**, which changed the on-disk data layout (version-specific subdirectories under `/var/lib/postgresql`). Postgres 18 **refuses to start** on a `*-postgres-data` volume written by an older Postgres, failing with `PostgreSQL data in: /var/lib/postgresql`. This affects local Aspire dev only — production deploy stacks pin their own Postgres and are unaffected. Fix: remove the stale volume and relaunch; Aspire re-runs `apply --seed` + `seed-demo` to rebuild and reseed it.
```bash
docker volume rm fsh-starter-postgres-data # use your app's prefix; e.g. acme-store-postgres-data
dotnet run --project src/Host/FSH.Starter.AppHost
```
## 2026-06-01
Post-10.0.0 pre-release hardening from a module-by-module audit (correctness, security, completeness, test coverage) across the backend. Every finding was adversarially verified before fixing; the suite stays green (warnings-as-errors, Testcontainers integration tests).
- **Chat: restoring an archived channel no longer loses its members (fix).** Archiving a channel called `db.Remove(channel)`, which cascaded the delete onto the `ChannelMember` rows; because the soft-delete interceptor only rescues *owned* references, the members were hard-deleted and a later **restore brought back an empty channel** — the creator then got `404` trying to post. Archiving is now an explicit domain state change that flips the soft-delete flag and leaves membership intact, so a restore is lossless.
- **Tickets: the lifecycle and permission set are now complete.** The `Closed` state was unreachable (no way to get there) and the `Tickets.Update` / `Tickets.Delete` permissions were registered with **no endpoints behind them** — so an admin could grant rights that did nothing, and the existing trash/restore had no way to actually trash a ticket. This adds first-class **Close** (`POST /api/v1/tickets/{id}/close`, Resolved → Closed), **Update** (`PUT /api/v1/tickets/{id}` — edit title/description/priority; frozen once Closed), and **Delete** (`DELETE /api/v1/tickets/{id}` — soft-delete; comments survive and return on restore), each with a validator, a permission gate, and a new `Tickets.Close` permission. `GET /api/v1/tickets/{id}/comments` now returns `404` for a non-existent ticket instead of a misleading empty list. See [Tickets](/docs/modules/tickets/).
- **Webhooks: signing secrets are encrypted at rest, and the endpoints are permission-gated (security fixes).** The HMAC signing secret was stored as plaintext in a field named `SecretHash` — a database breach would have exposed every tenant's secret. The secret is the HMAC key (it must stay recoverable, so hashing isn't an option), so it is now **encrypted with ASP.NET Data Protection** on create and decrypted only at sign time. Separately, the endpoints were authentication-only — any signed-in user could manage every webhook in their tenant; they now require the new `Webhooks.View` / `Create` / `Delete` / `Test` permissions. **After upgrading, grant these to the roles that manage webhooks or those users will get `403`.** See [Webhooks](/docs/modules/webhooks/).
- **Multitenancy & correctness.** The `GET tenant provisioning status` and `retry provisioning` endpoints now accept and forward a `CancellationToken` (graceful shutdown); the Notifications mention handler fails loud on a tenant-context mismatch rather than risking a cross-tenant write; webhook list endpoints validate pagination (a `pageSize=0` previously surfaced as a `500`, now a clean `400`); and the Billing monthly-invoice job takes its clock from `TimeProvider` for deterministic tests.
- **Known follow-up.** Billing publishes its `InvoiceIssued` event directly on the in-memory bus rather than through the transactional outbox (Files does the same, with no consumer yet). Closing this needs a small `Eventing` building-block change to support more than one outbox-backed module; tracked for a follow-up release. The in-memory path is correct today.
## 10.0.0 — 2026-05-28
The first stable **10.0.0** release. fullstackhero is now a complete **.NET 10 modular monolith plus two React 19 apps** — and you get the **full source**, no black-box runtime packages. Available today via `git clone` or the GitHub template; the `fsh` CLI and the `dotnet new fsh` template publish to NuGet shortly.
- **Backend** — .NET 10 / EF Core 10 modular monolith (Vertical Slice + source-generated Mediator CQRS) across **10 modules**: Identity, Multitenancy, Billing, Catalog, Tickets, Chat, Files, Webhooks, Auditing, and Notifications. Multitenant by default (Finbuckle), JWT + ASP.NET Identity, HybridCache on Valkey, Hangfire jobs, presigned S3/MinIO storage, OpenAPI + Scalar, and Serilog + OpenTelemetry.
- **Front-ends** — two **React 19 + Vite 7 + TypeScript** apps: an operator console (`admin`) and a tenant app (`dashboard`), with TanStack Query v5, Tailwind v4, and SignalR/SSE real-time.
- **One-command local dev** — **.NET Aspire** brings up Postgres + pgAdmin, Valkey + RedisInsight, MinIO, the migrator, demo data, the API, and both front-ends. **Docker Compose** and **AWS/Terraform** cover deployment.
- **Tested & enforced** — 1,600+ backend tests (xUnit, Testcontainers, NetArchTest boundaries) and 200+ Playwright E2E tests, with path-scoped backend/frontend CI and warnings-as-errors.
- **Polish in this release** — the `fsh` CLI gained a `--version` flag and a corrected (semver-aware) update check; the unimplemented `--db sqlserver` scaffold option was removed (PostgreSQL is the supported provider); AppHost resource names are namespaced per app; and a batch of scaffold/DX fixes landed (see the dated entries below).
See the dated entries below for the complete list of changes that shipped into 10.0.0.
## 2026-05-30
- **Cross-tenant hardening across billing, subscriptions, and tenant management (security fixes).** A deep audit found several handlers that read or mutated data scoped only by a caller-supplied id rather than the caller's tenant. Because `BillingDbContext` is intentionally non-tenant-filtered (so the root operator can see across tenants), each handler must scope explicitly — and several didn't. A tenant admin (who holds the basic `Billing.View`/`Billing.Manage` permissions) could read, **issue, pay, or void another tenant's invoices** by id, **reassign or cancel another tenant's subscription** via a body `tenantId`, **read or fabricate another tenant's usage**, or trigger **platform-wide invoice generation**. The by-id read/PDF paths and every mutation path now gate on the root operator — the operator acts cross-tenant, every other tenant is pinned to its own — and `POST /api/v1/billing/invoices/generate` is now operator-only. Separately, a role-permission filter only stripped a `Permissions.Root.` name prefix that matches **no** real operator permission, so a non-root tenant admin with `Roles.Update` could grant their own role the operator-only `Tenants.*` / `Platform.*` permissions and escalate to managing every tenant; the filter now keys off the registered `IsRoot` flag. Existing isolation tests missed all of this because they always authenticate with a *matching* tenant header — they never exercised one tenant's token acting on another's data. New integration tests cover each scenario. (The `tenant`-header-vs-JWT-claim path was investigated and is **not** affected — Finbuckle's claim strategy binds the resolved tenant to the JWT claim for non-root callers.)
- **The API now serializes enums as their string names (contract change).** Every enum in an API response is emitted as its name (`"Active"`, `"Paid"`, `"Security"`) instead of a numeric value, via a global `JsonStringEnumConverter`; reading still accepts either form, so request bodies are unaffected. `[Flags]` enums (`AuditTag`, `BodyCapture`) stay numeric. Both bundled React apps already mirror this as string-union types — but if you consume the API from your own client, update any code that switched on numeric enum values. Previously only a couple of modules opted in per-type, so values like a subscription's `status` serialized as `0` and surfaced as a stray "0" in the dashboard.
- **Billing correctness.** The monthly usage/overage invoice was silently skipped for any month that already had a subscription invoice (the idempotency check ignored the invoice *purpose*), so overage went unbilled — it's now scoped to the usage invoice. A same-plan renewal advanced the tenant's validity but left the subscription's end date unchanged, so the dashboard's subscription term drifted behind the enforced validity; a renewal now extends the subscription term too. Tenant provisioning now checks the admin-user creation result instead of ignoring it (a silent failure previously marked a tenant "provisioned" with no usable admin login). Voiding an invoice is idempotent, invoice-list page size is capped at 100, and the root operator tenant's validity can no longer be adjusted.
- **Front-end polish.** The **admin** console hides plan/invoice/tenant action buttons from operators who lack the matching permission (they previously appeared and failed with `403` on submit) and shows a real error state on the invoice page instead of a stuck "Loading…". The **dashboard** landing page's validity now reflects an in-grace or expired tenant (with a persistent expired banner) instead of a healthy day count, surfaces subscription/invoice load errors instead of masking them as an empty state, and paginates the invoice list.
## 2026-05-28
- **Tenant billing is now complete end-to-end — expiry/renewal emails, PDF invoices, and a tenant-facing billing view.** Building on the plan-driven subscription/invoice lifecycle, this round finishes the SaaS billing story. A daily Hangfire scan (`tenant-expiry-scan`, 02:00 UTC) classifies every active tenant as *nearing expiry*, *in grace*, or *expired* and emails the tenant admin — deduped so each state notifies once per validity window (and re-arms automatically on renewal). Issuing an invoice now also emails the tenant. Invoices are downloadable as **PDF** (`GET /api/v1/billing/invoices/{id}/pdf`, QuestPDF behind a swappable `IInvoicePdfRenderer`); the download is tenant-scoped, so one endpoint safely serves both the operator console and tenant self-service. The **dashboard** gains a `/subscription` page (plan, validity, usage, recent invoices), a global expiry/grace **warning banner**, and invoice detail with PDF download; the **admin** console gets a PDF button, client-side plan-form validation, and an **Adjust validity** operator override (`POST /tenants/{id}/adjust-validity`) that sets a tenant's expiry directly with no invoice — for comps and corrections. New config key `Billing:ExpiryNotificationLeadDays` (default 7). *Note: QuestPDF's Community license is free for organisations under $1M USD/year revenue; larger commercial users must obtain a license — the dependency is isolated behind `IInvoicePdfRenderer` if you prefer to swap it.*
- **Background-published lifecycle events no longer crash the webhook fan-out (fix).** The generic webhook fan-out handles every integration event and reads a tenant-filtered context that captures the ambient tenant *at construction* — so events published from a background job (no HTTP request) hit a null tenant and threw. Background publishers (the new expiry scan) now install the tenant context before publishing, so the webhook fan-out and email handlers run correctly. The renewal stacking math also now uses the injected clock (was `DateTime.UtcNow`), and a `X-Subscription-Grace` response header reports the days left while a tenant is in its grace window.
- **Chat delivers messages live to recipients who weren't in the conversation when they connected** — chat broadcasts each message to the channel's SignalR group, but a connection only joined the groups for channels it already belonged to *at connect time* (`AppHub.OnConnectedAsync`). So a brand-new DM, or being added to a channel mid-session, never received live messages — the recipient saw nothing until they reloaded the page. The hub now exposes a membership-checked `JoinChannel` method that the dashboard invokes when a conversation is opened and again on reconnect, so a live socket joins the group on demand. Creating a DM also notifies the other participants (via their `user:{id}` group), so the new conversation appears in their channel rail without a refresh.
- **Deactivated tenants are now actually blocked (security fix)** — deactivating a tenant only flipped an `IsActive` flag in the tenant store; nothing in the auth or request pipeline enforced it, so a deactivated tenant's users could still log in and use the API. Tenant resolution now rejects requests for a deactivated tenant with `403 Forbidden` — covering login, token refresh, and every API/realtime request — via a post-authentication guard. Operators (the root tenant) are exempt so they can still manage and reactivate tenants. Deactivation also now invalidates the tenant's distributed-cache entry, so the change takes effect on the very next request instead of waiting out the 60-minute cache.
## 2026-05-27
- **Dependencies updated to latest for the v10 release** — .NET **Aspire 13.3.5** (Hosting packages + AppHost SDK), Finbuckle.MultiTenant 10.1.0, MailKit/MimeKit 4.17.0, AWSSDK.S3 4.0.23.4, Scalar.AspNetCore 2.14.14, and SonarAnalyzer 10.27. Builds clean with warnings-as-errors and the full test suite (unit + Testcontainers integration) stays green.
- **Template packaging fixes — scaffolded Dockerfiles and dev-machine packing** — `dotnet new fsh` / `fsh new` packed extensionless files (every `Dockerfile`) to a doubled nested path, so scaffolded projects got a `Dockerfile` *directory* instead of a file and `deploy/docker` (`docker compose up`) was broken. Also made the IDE-cache excludes (`.vs`/`.idea`/`.vscode`) recursive so `dotnet pack` no longer fails (or bundles IDE junk) when packing the template on a developer machine. Scaffolded output now builds and self-hosts cleanly.
- **Scaffolded apps log in out of the box, get isolated data volumes, and start on `main`** — three `fsh new` / Aspire DX fixes: the AppHost migrator now runs `apply --seed`, so the root admin (`admin@root.com`) is seeded automatically — previously a freshly-run app came up with an empty user table and **nobody could log in**; each app's Docker volumes are namespaced by app name (e.g. `myapp-postgres-data`) instead of sharing a literal `postgres-data`, so two FSH-based apps on one machine no longer clobber each other's database; and `fsh new` initializes git on `main` rather than following the machine's git default (often `master`).
- **Demo logins (`acme`/`globex`) work on a fresh Aspire launch** — the dashboard's demo-login panel advertised accounts that were never seeded: the AppHost migrator ran only `apply --seed` (which seeds the root admin), while the `acme`/`globex` demo tenants are created by the dev-only `seed-demo` verb. Aspire now runs `seed-demo` as a dedicated demo-seeder step after migration — so `admin@acme.com` / `Password123!` works the moment the dashboard loads. Also fixes the migrator crashing at startup in Development (its trimmed service graph tripped the DI container's build-time validation) and corrects the verb's environment gate to `DOTNET_ENVIRONMENT` (the migrator is a generic-host console app, not a web host).
- **Aspire resource names are namespaced per app** — the AppHost's resource/container names (API, migrator, demo-seeder, admin, dashboard) now derive from the app's namespace, like the Docker volume names already did. A scaffolded `Acme.Store` shows `acme-store-api` etc. instead of the kit's literal `fsh-*`, so two FSH-based apps on one machine don't collide. (This repo resolves to `fsh-starter-*`; the `postgres`/`redis`/`minio` infra and the `fsh-db` database keep stable names.)
- **Stale sessions resolve cleanly instead of erroring** — both React apps (admin + dashboard) treated an expired token left in `localStorage` as signed-in, firing protected requests that 401'd in a loop (`SecurityTokenExpiredException`). On boot they now attempt one silent token refresh: success restores the session, failure routes to `/login`. Long-lived sessions still refresh transparently mid-use.
- **CI split into path-scoped backend + frontend pipelines** — the single `ci.yml` is replaced by `backend.yml` (runs only on `src/**` changes) and `frontend.yml` (runs only on `clients/**`), so a client-only change never builds or tests the API, and vice versa. The SDK is pinned to the .NET 10 **GA** release via a root `global.json` (no more preview channel). Unit and integration tests each run **once**, and the coverage gate merges their results instead of re-running the whole solution. The React apps get real CI for the first time — ESLint, `tsc`/Vite build, and the Playwright E2E suites (admin + dashboard) on Node 22. Branch protection requires the always-resolving `Backend CI` / `Frontend CI` gate jobs. See [CI/CD](/docs/deployment/ci-cd/).
- **Consolidated to a single `main` branch** — the repo now uses one long-lived default branch, `main`; the `develop` branch is retired. Branch from and target `main`; stable releases are cut from `v*` tags. See [Contributing](/docs/contributing/).
- **Removed the redundant root `docker-compose.yml`** — local development is covered by .NET Aspire and production by `deploy/docker/`, so the overlapping root compose file (added 2026-05-24) was dropped.
- **Missing required request parameters now return `400`, not `500`** — calling a tenant-scoped endpoint without the `tenant` header (and any other endpoint missing a required header/route/query parameter, or sent with an unreadable/oversized body) raised an ASP.NET `BadHttpRequestException` that the global exception handler rendered as a generic `500 Internal Server Error`. The handler now honours the framework's own status code, so these surface as a proper `400 Bad Request` (or `413`, etc.) with a `ProblemDetails` body. Fixes [#1245](https://github.com/fullstackhero/dotnet-starter-kit/issues/1245).
## 2026-05-24
- **Cache/store engine switched from Redis to Valkey 8** — the BSD-licensed, Linux Foundation fork of Redis. It's a drop-in over the Redis protocol (RESP): the `StackExchange.Redis` client and every `CachingOptions:Redis` config key are unchanged. Applies to .NET Aspire, both Docker Compose files, and the integration-test container.
- **RedisInsight cache browser** is now auto-wired in Aspire, connected to the Valkey instance so you can inspect cache keys, TTLs, and the SignalR backplane in local dev with no manual configuration.
- **Docker Compose hardening** — the production `deploy/docker` stack now provisions the MinIO bucket before the API starts (fixes a first-upload `NoSuchBucket`); the dev root `docker-compose.yml` now runs the DB migrator (`apply --seed`) so the API never boots against an empty schema.
---
# Overview
> The fsh CLI — scaffold projects, check your environment, and stay current.
Source: https://fullstackhero.net/docs/cli/
A NuGet-distributed dotnet tool ([Spectre.Console](https://spectreconsole.net/), source at `src/Tools/CLI`) for scaffolding fullstackhero projects and verifying your environment. Four commands: `new`, `doctor`, `info`, `update`.
```bash
dotnet tool install -g FullStackHero.CLI
```
`FullStackHero.CLI` is published as stable **10.0.0**. `fsh new` scaffolds from
the `FullStackHero.NET.StarterKit` template package (also 10.0.0), so the
install-then-scaffold flow works end-to-end. You can also run the CLI straight
from a clone of the repo:
```bash
dotnet run --project src/Tools/CLI -- doctor
```
## fsh new
Scaffolds a fresh, fully renamed project from the latest template. Run bare for the interactive wizard (project name, include Aspire?, include the React apps?), or pass everything as flags.
```bash
fsh new MyApp
fsh new MyApp --no-aspire --no-frontend
fsh new MyApp --non-interactive -o ./somewhere-else
```
| Flag | What it does |
|---|---|
| `[name]` | Project name — used for the solution, namespaces, and folders |
| `-o, --output` | Output directory (defaults to `./`) |
| `--no-aspire` | Exclude the .NET Aspire AppHost project |
| `--no-frontend` | Exclude the React admin + dashboard apps |
| `--skip-install` | Skip `npm install` for the React apps after scaffolding |
| `--non-interactive` | Skip prompts and use defaults (name becomes required) |
| `--git` | Initialize a git repository (on by default) |
| `--dry-run` | Show what would be created without creating anything |
Beyond renaming, `new` does the setup you'd otherwise do by hand: installs the template if it's missing, replaces the shared dev JWT signing key with a unique per-project one, generates `deploy/docker/.env` with strong random secrets so `docker compose up` works immediately, runs `npm install` in both React apps, and makes the initial commit on a `main` branch.
## fsh doctor
Checks your environment and prints a pass/warn/fail table:
- **.NET SDK** — present and version 10+
- **Git** — installed
- **Docker** — installed *and* running
- **FSH template** — installed (warns with the install command if not)
- **Ports** — `5030`, `7030` (API) and `15888` (Aspire dashboard) free
Exits non-zero if a required tool is missing, so it's scriptable.
## fsh info
Shows the CLI version, the latest version on NuGet (and whether an update is available), the installed and latest template versions, your .NET SDK version, and links to the docs and release notes.
## fsh update
Updates both halves in one go: `dotnet tool update -g FullStackHero.CLI` for the CLI, then reinstalls the latest `FullStackHero.NET.StarterKit` template.
---
# Overview
> Honest, fact-checked comparisons of fullstackhero against the most common .NET starter kits and frameworks — ABP, Clean Architecture templates, and BlazorPlate.
Source: https://fullstackhero.net/docs/compare/
fullstackhero is one of several ways to start a production .NET project. The three pages below compare it head-to-head with the most common alternatives so you can pick deliberately.
Each page is maintained by us, so it's our perspective. The intent is to be **specific about where each option diverges**, not to win an argument. Where any of these pages drifts out of date or treats an alternative unfairly, [open an issue](https://github.com/fullstackhero/dotnet-starter-kit/issues) and we'll fix it.
## Available comparisons
- **[fullstackhero vs ABP Framework](/docs/compare/fsh-vs-abp/)** — the biggest direct competitor. ABP is a complete opinionated framework with a paid commercial tier; fullstackhero is copy-and-own MIT source.
- **[fullstackhero vs Clean Architecture templates](/docs/compare/fsh-vs-clean-architecture/)** — Jason Taylor and Ardalis templates ship an *architecture*; fullstackhero ships ten production *modules* on top of one.
- **[fullstackhero vs BlazorPlate](/docs/compare/fsh-vs-blazorplate/)** — free open-source + React versus paid closed-source + Blazor.
## Quick decision tree
- **You want named vendor support + admin UI generator + heavyweight framework** → ABP Framework (its commercial tier).
- **You want to learn or assemble Clean Architecture from a clean slate** → Jason Taylor's or Ardalis's Clean Architecture template.
- **You want Blazor + paid support + one-time licence + i18n** → BlazorPlate.
- **You want free + open source + ten production modules + React + zero lock-in** → fullstackhero.
## Where to go next
- [Quick Start](/docs/getting-started/quick-start/) — try fullstackhero in a minute.
- [Architecture overview](/docs/architecture/) — modular monolith + Vertical Slice Architecture.
- [Modules](/docs/modules/) — the ten modules that ship in v10.
---
# fullstackhero vs ABP Framework
> A fair comparison of fullstackhero and ABP Framework for building production .NET 10 applications. License, architecture, learning curve, and when to pick which.
Source: https://fullstackhero.net/docs/compare/fsh-vs-abp/
ABP Framework and fullstackhero both target the same problem — getting a production-grade .NET application off the ground without spending months on the infrastructure plumbing every multi-tenant app needs. They pick very different shapes to solve it. This page is an honest comparison so you can pick the right one for your team.
> **TL;DR.** ABP is a complete opinionated framework — its own DI conventions, its own CLI, its own module system, a paid commercial tier with admin UI and support contracts. fullstackhero is a copy-and-own starter: standard ASP.NET Core, plain DI, free under MIT, with ten production modules pre-wired and the source code in your repo from day one. If you want named vendor support and an admin app generator, pick ABP. If you want to own the code and use the .NET your team already knows, pick fullstackhero.
## Side-by-side
| | **fullstackhero** | **ABP Framework** |
|---|---|---|
| License | MIT — every line | LGPL (free) + ABP Commercial (paid) |
| Cost | Free | Free tier; ABP Commercial from \$1,490+/yr (per team / app) |
| Distribution | Clone the repo, install `FullStackHero.CLI`, or `dotnet new fsh` | NuGet packages (`Volo.Abp.*`) + ABP Studio / ABP Suite |
| Architecture | Modular monolith with Vertical Slice Architecture inside each module | Layered DDD module system (Domain / Application / HttpApi / Web / EntityFrameworkCore per module) |
| Code ownership | You own the entire codebase from day one | You consume Volo NuGet packages; the framework lives upstream |
| DI | Standard `Microsoft.Extensions.DependencyInjection` | Custom `IServiceCollection` extensions and module-level conventions |
| Mediator | Mediator 3.0.2 source generator (no runtime reflection) | ABP's own service registration + dynamic proxying |
| Validation | FluentValidation 12.1 | DataAnnotations + ABP's `IValidatableObject` extensions |
| Multitenancy | Finbuckle.MultiTenant 10.1 — claim / header / query strategies | First-party module: `Volo.Abp.MultiTenancy` |
| ORM | EF Core 10 (PostgreSQL via Npgsql by default; SQL Server provider available) | EF Core with abstraction layer + LINQ via repository pattern |
| Background jobs | Hangfire 1.8 | ABP's `IBackgroundJobManager` (multiple providers) |
| Realtime | SignalR + Server-Sent Events | SignalR via ABP modules |
| Observability | Serilog 4 + OpenTelemetry 1.15 (OTLP exporter), pre-wired | OpenTelemetry support; configuration via ABP modules |
| Frontend included | React + Vite admin and dashboard, in the repo | MVC + Razor / Blazor / Angular options — Lepton theme is paid in ABP Commercial |
| CLI scaffolding | `fsh new ` | `abp new ` (Studio offers a GUI) |
| Stars (May 2026) | 6.4k★ | 14.2k★ |
| Maintainer | Mukesh Murugan + community | Volosoft (commercial company) |
## When to choose ABP Framework
You should pick ABP — happily — if any of the following are true:
- **You need vendor support contracts.** ABP Commercial includes a paid support tier with SLAs. fullstackhero is community-maintained; there is no enterprise hotline.
- **You want a UI admin app generator.** ABP Studio and ABP Suite can scaffold CRUD UIs against your entities. fullstackhero ships an admin console but not a code generator for new screens.
- **You'd rather consume framework upgrades as NuGet bumps.** ABP's framework lives in your package references. Major framework improvements arrive as `dotnet add package` upgrades. fullstackhero is copy-and-own — improvements arrive as upstream commits you cherry-pick.
- **Your team is already fluent in ABP conventions.** ABP's module system, DI extensions, and repository pattern are a real learning investment. If you've made it, throwing it away is expensive.
## When to choose fullstackhero
Pick fullstackhero — also happily — if any of these match:
- **You want the code in your repo, not a framework above it.** Every file is yours. Every line is editable. No DLL surprises during a major .NET upgrade; no licence changes to react to.
- **You'd rather use the .NET your team already knows.** Plain ASP.NET Core minimal APIs, plain `IServiceCollection`, Mediator + FluentValidation. Anyone who knows current-day .NET can be productive in fullstackhero within an afternoon.
- **You need the entire stack free under MIT.** ABP's framework is LGPL, but the commercial admin UI, theme, code generators, and support are paid. fullstackhero is end-to-end free including the React admin console and the dashboard.
- **You want Vertical Slice Architecture inside a modular monolith.** ABP defaults to layered DDD per module (Domain / Application / HttpApi / …). fullstackhero defaults to one feature, one folder, one PR. Both are valid; pick the one that matches how you ship.
- **You want zero vendor lock-in.** No upstream framework to track. No commercial roadmap that might pivot away from your needs. Fork the kit, freeze it, and never look back if that's what your project requires.
## What the two share
Worth saying out loud: ABP and fullstackhero agree on most of what matters.
- Both are production-ready, multi-tenant by default, with identity, audit, background jobs, caching, and OpenAPI built in.
- Both ship as full reference implementations with hundreds of integration tests.
- Both can scale to large teams and large codebases — ABP through its module catalogue, fullstackhero through extract-a-module-to-a-service when you need to.
The choice is mostly about **how much framework you want above your code**, and **whether you need paid support**.
## Migration notes
If you're moving from ABP to fullstackhero (or thinking about it):
- **Modules map.** ABP's `Volo.Abp.Identity` lines up with fullstackhero's `Modules.Identity`. `Volo.Abp.MultiTenancy` lines up with `Modules.Multitenancy`. `Volo.Abp.AuditLogging` lines up with `Modules.Auditing`.
- **Repositories become DbContexts.** Where ABP uses `IRepository` and the Specification pattern, fullstackhero uses EF Core `DbSet` directly inside the slice handler. Less indirection; same SQL.
- **App services become Mediator handlers.** What ABP calls an "application service" is split into a command/query + handler in fullstackhero. The contract lives in `Modules.X.Contracts`; the handler lives in `Modules.X`.
- **No upstream NuGet to keep current.** Once you've cloned fullstackhero you own the code. There's no `dotnet add package Volo.Abp.X` upgrade cycle.
## Honest disclaimers
This page is maintained by fullstackhero, so treat it as our perspective. ABP is a serious project with a mature community and a long track record — for many teams it's the right answer. The intent here is not to win an argument, just to be specific about where the two diverge so you can pick deliberately.
If anything on this page is out of date or unfair to ABP, please [open an issue](https://github.com/fullstackhero/dotnet-starter-kit/issues) — we'll fix it.
## Where to go next
- [Architecture overview](/docs/architecture/) — how fullstackhero is put together.
- [Modules](/docs/modules/) — the ten modules that ship in v10.
- [Quick Start](/docs/getting-started/quick-start/) — try fullstackhero in under a minute.
- [ABP Framework documentation](https://docs.abp.io/) — the canonical reference for the alternative.
---
# fullstackhero vs BlazorPlate
> How fullstackhero compares to BlazorPlate — the difference between a free, open-source .NET 10 starter kit and a paid closed-source SaaS template.
Source: https://fullstackhero.net/docs/compare/fsh-vs-blazorplate/
BlazorPlate is a paid commercial multi-tenant SaaS starter for .NET, sold as a one-time license. fullstackhero is a free, MIT-licensed open-source starter that targets the same job: spinning up a multi-tenant .NET SaaS without writing months of plumbing. This page is an honest comparison so you can make the call deliberately.
> **TL;DR.** BlazorPlate is a polished commercial template for Blazor-first teams who want a finished product with paid support behind it. fullstackhero is free and open-source, ships two React frontends instead of Blazor, and gives you the full source under MIT. If you need Blazor + paid support + a single one-time payment, BlazorPlate is built for that. If you want zero cost, open source, and React frontends, fullstackhero fits.
## Side-by-side
| | **fullstackhero** | **BlazorPlate** |
|---|---|---|
| License | MIT — open source | Commercial — closed source |
| Cost | Free | Standard \$499 (one developer) · Enterprise \$999 (team) — one-time payment |
| Source access | Full source on GitHub | Source delivered after purchase; redistribution restricted |
| Distribution | `git clone` / `dotnet new fsh` / `fsh new` | Download bundle after purchase |
| Architecture | Modular monolith + Vertical Slice Architecture | Clean Architecture |
| Multitenancy | Finbuckle.MultiTenant 10 — claim / header / query strategies, EF Core global query filter | Built-in — dedicated, shared, single-tenant deployment modes |
| Identity | ASP.NET Identity + JWT + refresh + permissions + impersonation | ASP.NET Identity + JWT + permissions |
| Frontend | React 19 + Vite admin console + tenant dashboard (both in the repo) | Blazor Server + Blazor WebAssembly |
| i18n | Not built-in | 20+ languages, RTL support |
| Background jobs | Hangfire 1.8 | Hangfire |
| Observability | Serilog 4 + OpenTelemetry 1.15 (OTLP) | Serilog |
| Realtime | SignalR (Valkey backplane) + Server-Sent Events | SignalR |
| File storage | Tenant-scoped MinIO / S3 abstraction | File storage primitives |
| Email | MailKit / SendGrid | Email service |
| Webhooks | Tenant-scoped subscriptions + HMAC-signed payloads | Not included |
| API browser | Scalar (OpenAPI 3.1) | Swagger |
| Live demo | Not yet | Yes, hosted |
| Support | Community (GitHub issues + discussions) | Paid email support |
| Updates | Pull from upstream, your call | Free for 12 months after purchase |
| Maintainer | Mukesh Murugan + community | Commercial product company |
## When BlazorPlate is the right call
There are real reasons to pick BlazorPlate:
- **You're a Blazor team.** BlazorPlate ships a complete Blazor Server + WebAssembly experience tuned to the product. fullstackhero ships React frontends — if you'd rather not maintain React, BlazorPlate is the closer fit.
- **You need built-in internationalisation.** BlazorPlate ships with 20+ languages and right-to-left layout support. fullstackhero is English-only out of the box; you'd add i18n yourself.
- **You want one paid support email address.** A commercial product gives you a single inbox to escalate to. fullstackhero is community-maintained; questions go through GitHub Discussions and issues, no SLA.
- **You're comfortable with closed source for the speed.** Some teams trade source-access for "it works, ship it." If that's you, BlazorPlate is engineered for that trade.
- **You prefer a one-time licence over a subscription.** BlazorPlate is a flat fee. No recurring billing. Some teams strongly prefer that model.
## When fullstackhero is the right call
Pick fullstackhero if any of these apply:
- **You want zero cost, end-to-end.** Hobby project, side project, venture-funded startup — the license is the same: free under MIT. No \$499 cheque, no licence renewal, no per-seat math.
- **You need the source open.** Open source on day one, on GitHub, with the full commit history. Audit it. Fork it. Submit pull requests. Send to a security team.
- **You want React, not Blazor.** Two production React + Vite apps ship in the repo (admin console + tenant dashboard). If your team is React-native, you're not paying a Blazor tax.
- **You want vertical-slice features, not layered CRUD.** fullstackhero defaults to one feature = one folder. BlazorPlate is layered Clean Architecture — same valid pattern, different shape.
- **You want webhook delivery built in.** fullstackhero ships tenant-scoped webhook subscriptions with HMAC-signed payloads (handy for integrating tenants' systems with yours). BlazorPlate doesn't ship a webhooks module.
- **You want OpenTelemetry traces / metrics / logs out of the box.** fullstackhero wires the full OTLP exporter for traces, metrics, and logs. BlazorPlate ships Serilog only.
## A fair shared list
Worth saying: both kits are mature, production-grade work. Both:
- Are multi-tenant by default.
- Cover identity, jobs, caching, file storage, email, OpenAPI, and audit.
- Have an admin UI in their respective frontend tech.
- Are designed for B2B SaaS deployments.
The biggest split is **open source + free + React** (fullstackhero) versus **closed source + paid + Blazor** (BlazorPlate). The other deltas are detail.
## What about a hybrid?
If you've already bought BlazorPlate and want the things fullstackhero adds (Webhooks, OpenTelemetry, additional sample modules), you can lift specific modules across — each fullstackhero module is its own project and only depends on its sibling `Contracts` assembly + the BuildingBlocks. The reverse — porting BlazorPlate features into fullstackhero — is restricted by BlazorPlate's licence, so check before doing it.
## Honest disclaimers
This page is maintained by fullstackhero, so it's our perspective. BlazorPlate is a real product with real customers — if the trade-offs above point you at it, you'll be in good hands. The point of this page isn't to win; it's to be specific about where the two diverge so you can pick deliberately.
If anything here is out of date or unfair to BlazorPlate, please [open an issue](https://github.com/fullstackhero/dotnet-starter-kit/issues) — we'll fix it.
## Where to go next
- [Quick Start](/docs/getting-started/quick-start/) — try fullstackhero in under a minute.
- [Architecture overview](/docs/architecture/) — Vertical Slice inside a modular monolith.
- [vs ABP Framework](/docs/compare/fsh-vs-abp/) — the other big comparison.
- [BlazorPlate](https://www.blazorplate.net/) — the commercial alternative.
---
# fullstackhero vs Clean Architecture templates
> How fullstackhero compares to the Jason Taylor and Ardalis Clean Architecture templates — the difference between shipping an architecture and shipping production modules on top of one.
Source: https://fullstackhero.net/docs/compare/fsh-vs-clean-architecture/
The Clean Architecture templates by Jason Taylor and Steve Smith (Ardalis) are the two most popular starting points for new .NET projects on GitHub. Together they sit at around 38,000 stars. They're excellent — and they solve a different problem than fullstackhero.
> **TL;DR.** Clean Architecture templates ship an *architecture*: the four-project layout, dependency inversion, MediatR plumbing, a sample feature. fullstackhero ships ten production *modules* on top of an architecture: Identity, Multitenancy, Auditing, Files, Chat, Notifications, Webhooks, Billing, Catalog, Tickets — already wired through a modular monolith. Pick a CA template if you want to learn or assemble; pick fullstackhero if you want to start shipping features today.
## What each template actually contains
| | **Jason Taylor CA** | **Ardalis CA** | **fullstackhero** |
|---|---|---|---|
| Project layout | Domain / Application / Infrastructure / Web | Core / Infrastructure / Web | BuildingBlocks / Modules (each with runtime + Contracts) / Host |
| Architectural style | Clean Architecture (layered + dependency inversion) | Clean Architecture (layered + dependency inversion) | Modular monolith + Vertical Slice Architecture per module |
| Sample feature | One sample (e.g. TodoList) | One sample | Ten production modules + sample features per module |
| Identity / Auth | Stub / sample | Stub / sample | JWT bearer + refresh + permissions + impersonation + rate-limited auth flows |
| Multitenancy | Not included | Not included | Finbuckle 10 with EF Core global query filter, IGlobalEntity opt-out, tenant-aware cache + jobs + outbox |
| Auditing | Not included | Not included | EF SaveChanges interceptor + per-entity before/after + queryable `/audits` |
| Background jobs | Not included | Not included | Hangfire 1.8 with tenant context preserved |
| Observability | Console logging | Serilog example | Serilog 4 + OpenTelemetry 1.15 (OTLP) pre-wired |
| Caching | Not included | Not included | HybridCache (in-memory + Valkey) with tenant-scoped invalidation |
| API browser | Swagger | Swagger | Scalar (OpenAPI 3.1) on `/scalar/` |
| Frontend | Angular 21 / React 19 (sample) | None (API-only) | React 19 + Vite admin + dashboard, in the repo |
| Stars (May 2026) | ~20,100★ | ~18,200★ | ~6,400★ |
| License | MIT | MIT | MIT |
| Maintainer | Jason Taylor | Steve "Ardalis" Smith | Mukesh Murugan + community |
## What Jason Taylor's template is great at
Jason Taylor's [`CleanArchitecture`](https://github.com/jasontaylordev/CleanArchitecture) template is the de-facto reference for layered CA in .NET. It ships:
- A polished four-project layout (Domain / Application / Infrastructure / Web).
- A complete sample feature — `TodoList` with CRUD endpoints, validators, MediatR handlers.
- Frontend SPAs to choose between (Angular 21 or React 19).
- Excellent code commentary so you can learn CA conventions by reading.
It is intentionally minimal in what it *adds*. There is no identity beyond ASP.NET Identity scaffolding, no multitenancy, no observability beyond console logging, no file storage, no webhooks, no realtime. That is by design — the template's job is to teach you the layout and let you fill in the rest.
## What Ardalis's template is great at
Ardalis's [`CleanArchitecture`](https://github.com/ardalis/CleanArchitecture) is the other canonical CA template. It ships:
- A simpler three-project layout (Core / Infrastructure / Web) tuned for the common case.
- The same dependency-inversion discipline as Jason Taylor's, with NuGet packages (`Ardalis.Specification`, `Ardalis.Result`, `Ardalis.GuardClauses`) Steve maintains alongside.
- A long pedagogical lineage — there's a Pluralsight course and an O'Reilly book that walk through it.
- An explicit statement in the README: "this template does not include every possible framework or feature."
If you want CA the way Steve teaches it, this is the most authentic source.
## What fullstackhero is great at
fullstackhero starts from a different premise: most production B2B and SaaS apps are going to need the same ten or so modules anyway (identity, multitenancy, auditing, file storage, chat, notifications, webhooks, billing, catalog, tickets) plus the same dozen infrastructure pieces (caching, jobs, observability, idempotency, feature flags, …). So ship them.
That means:
- **Day one is feature work.** The unglamorous parts are done. You don't decide where the JWT keys live, how multitenancy plugs into EF Core, how Hangfire respects tenant context, or how to wire OpenTelemetry — they're decided, wired, and tested.
- **The architecture is Vertical Slice inside a modular monolith.** Each feature lives in one folder — endpoint, command, handler, validator, tests. No three-layer round-trip to add a button.
- **Two React frontends ship in the repo** (admin console + tenant dashboard), so you don't reinvent the auth-flow / permission-UI / audit-viewer wheel.
## Honest trade-offs
There are real reasons to pick a CA template over fullstackhero:
- **You want to *learn* Clean Architecture.** Jason Taylor's template is the better classroom. fullstackhero assumes you know what handlers, repositories, and the dependency rule are.
- **You're building a small focused service** that doesn't need ten modules. fullstackhero is over-engineered for a single-purpose internal tool. Use a CA template (or `dotnet new webapi`) instead.
- **Your team is already deeply invested in CA.** If three years of code is structured around the four-project layout and `IApplicationDbContext`, switching to Vertical Slice is a real conversion. Refactor in place rather than swap.
- **You want a layered service / domain split** that lets non-feature-developers work on infrastructure without touching features. CA's layer split makes that explicit; VSA blurs it.
## Can I use Clean Architecture inside fullstackhero?
Yes, with caveats. Each fullstackhero module is its own project — nothing stops you from organising the *inside* of a module as Domain / Application / Infrastructure layers. The kit's outer shape (modular monolith with `*.Contracts` boundaries) stays the same; the inner shape becomes CA.
You'd be paddling against the grain, though. The defaults — endpoint + handler + validator in one folder — are tuned for Vertical Slice. If you want strict CA discipline, a CA template is a less ambiguous starting point.
## Where to go next
- [Architecture overview](/docs/architecture/) — Vertical Slice Architecture inside a modular monolith.
- [Quick Start](/docs/getting-started/quick-start/) — try fullstackhero in a minute.
- [vs ABP Framework](/docs/compare/fsh-vs-abp/) — the other big alternative.
- [Jason Taylor's `CleanArchitecture`](https://github.com/jasontaylordev/CleanArchitecture) — if a CA template is the right call.
- [Ardalis's `CleanArchitecture`](https://github.com/ardalis/CleanArchitecture) — the other canonical CA template.
---
# Overview
> Help shape fullstackhero — branching model, issues, PRs, and conventions.
Source: https://fullstackhero.net/docs/contributing/
# Contributing
See the repo's [CONTRIBUTING.md](https://github.com/fullstackhero/dotnet-starter-kit/blob/main/CONTRIBUTING.md) for the latest guidelines.
## Branching model
The repo uses a **single long-lived branch: `main`** (the default). There is no `develop` branch.
- **Branch from and target `main`.** Open your feature/fix branch off the latest `main` and point your PR back at `main`.
- **Releases are tags, not a branch.** Stable releases are cut from `v*` tags (e.g. `v10.0.0`); "latest stable" is the newest tag/GitHub Release, and `main` stays releasable. Older majors, if maintained, live on short-lived `release/x.y` branches.
- **CI gates the merge.** A PR must pass the `Backend CI` and/or `Frontend CI` checks (whichever your change touches — see [CI/CD](/docs/deployment/ci-cd/)) before it can merge.
Rebase onto `main` before requesting review so your PR applies cleanly and CI runs against the current baseline.
---
# Overview
> Platform features that span every module — caching, jobs, observability, idempotency, feature flags, rate limiting, health, realtime, SSE, HTTP resilience, and error handling.
Source: https://fullstackhero.net/docs/cross-cutting-concerns/
Eleven platform features span every module in fullstackhero. They wire once at the host layer, then any module consumes them through the same interfaces and conventions — no per-module bespoke setup.
The kit's `FshPlatformOptions` (passed to `AddHeroPlatform`) toggle each optional sub-system. Caching, jobs, mailing, feature flags, SSE, realtime, and quotas are **off by default** — opt in to what you need. CORS, OpenAPI, OpenTelemetry, idempotency, and global exception handling are on by default because the cost is negligible.
## The eleven concerns
Each page covers one concern in depth — how it's wired, where the implementation lives, how modules consume it, and the gotchas worth knowing.
## Related
- [Web building block](/docs/building-blocks/web/) — the composition root that wires every concern.
- [Modules overview](/docs/modules/) — every module composes against these features.
- [Architecture: dependency injection](/docs/architecture/dependency-injection/) — how `AddHeroPlatform` ties it together.
---
# Background jobs
> Hangfire 1.8 wired with tenant context preservation, DI-aware scoped activation, OpenTelemetry instrumentation, and a basic-auth-gated dashboard.
Source: https://fullstackhero.net/docs/cross-cutting-concerns/background-jobs/
Hangfire 1.8 powers every background job in fullstackhero. The Jobs building block wraps it with four kit-specific filters: `FshJobFilter` preserves tenant + user context across enqueue → execute, `FshJobActivator` resolves jobs from a scoped DI container per execution, `HangfireTelemetryFilter` opens an OpenTelemetry activity per job, and `LogJobFilter` logs every state transition.
Without `FshJobFilter`, a job dequeued on a background thread has no `HttpContext` and therefore no tenant. The filter captures the current Finbuckle tenant info and user id at enqueue time and stores them as job parameters; `FshJobActivator` reads them back when the job dequeues and pushes them onto `IMultiTenantContextSetter` / `ICurrentUserInitializer` in the job's DI scope. The job sees the same tenant and user the originating request did — without you writing any boilerplate.
## How it's wired
```csharp
builder.AddHeroPlatform(o => o.EnableJobs = true); // turns on AddHeroJobs
```
`UseHeroPlatform` calls `UseHeroJobDashboard` for you, mounting the dashboard at `HangfireOptions:Route` (default `/jobs`).
Storage provider falls out of `DatabaseOptions:Provider`:
- `PostgreSQL` → `Hangfire.PostgreSql`
- `MSSQL` → SQL Server storage
The kit defaults to 5 workers, 30-second heartbeat, queues `default` + `email`, 30-second polling.
## Enqueueing a job
Inject `IJobService` and call `Enqueue` / `Schedule`:
```csharp
public sealed class SendInvoiceEmailCommandHandler(IJobService jobs)
: ICommandHandler
{
public ValueTask Handle(SendInvoiceEmailCommand cmd, CancellationToken ct)
{
jobs.Enqueue(j => j.RunAsync(cmd.InvoiceId, CancellationToken.None));
return ValueTask.FromResult(Unit.Value);
}
}
```
Hangfire serializes the call signature; the activator resolves `SendInvoiceEmailJob` from a fresh DI scope when the job dequeues. The job can inject `DbContext`, `IMailService`, `ICurrentUser`, etc.
## Recurring jobs in the kit
| Job (recurring job id) | Module | Cron |
|---|---|---|
| `MonthlyInvoiceJob` (`billing-monthly-invoices`) | Billing | `5 0 1 * *` — 00:05 UTC on the 1st of each month |
| `PurgeOrphanedFilesJob` (`files-purge-orphans`) | Files | `0 * * * *` — hourly |
| `PurgeDeletedFilesJob` (`files-purge-deleted`) | Files | `30 3 * * *` — 03:30 UTC daily |
| `AuditRetentionJob` (`auditing-retention`) | Auditing | `30 3 * * *` default (configurable; the job is a no-op until `Auditing:Retention:Enabled` is true) |
| `TenantExpiryScanJob` (`tenant-expiry-scan`) | Multitenancy | `0 2 * * *` — 02:00 UTC daily |
The eventing outbox is **not** a Hangfire job — it's dispatched by the framework's `OutboxDispatcherHostedService`. (The host even ships a cleanup service that removes retired `{module}-outbox-dispatcher` recurring jobs from older deployments.)
Register your own with `IRecurringJobManager`:
```csharp
recurringJobs.AddOrUpdate(
"my-hourly-job",
job => job.RunAsync(CancellationToken.None),
"0 * * * *", // top of every hour
TimeZoneInfo.Utc);
```
## Retry policies
Per-job retry via the `[AutomaticRetry]` attribute on the job method:
```csharp
// WebhookDispatchJob
[AutomaticRetry(
Attempts = 4,
DelaysInSeconds = new[] { 30, 120, 600, 3600 },
OnAttemptsExceeded = AttemptsExceededAction.Fail)]
public async Task DispatchAsync(/* ... */) { /* ... */ }
```
The Webhooks module uses this exact pattern — 5 attempts total (1 initial + 4 retries) with 30 s / 2 min / 10 min / 1 h exponential backoff. The Files purge jobs carry their own tuned attributes (`[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 120, 600])]` for orphans; 2 attempts for deleted-file purges).
## Dashboard
`UseHeroJobDashboard` (called inside `UseHeroPlatform`) mounts the Hangfire dashboard at `HangfireOptions:Route` (default `/jobs`), gated by `HangfireCustomBasicAuthenticationFilter`.
```jsonc
{
"HangfireOptions": {
"UserName": "admin",
"Password": "set-via-secrets", // min 12 chars — validated at startup
"Route": "/jobs"
}
}
```
There are no safe defaults: `UserName` (min 3 chars) and `Password` (min 12 chars) are `[Required]` and validated at startup via `ValidateDataAnnotations().ValidateOnStart()` — the host won't boot with jobs enabled and empty credentials. Local dev gets `admin` / `Password123!` from `appsettings.Development.json` (the Aspire AppHost injects the same); set real values via env vars or a vault in prod. Always HTTPS in production; consider IP whitelisting in the reverse proxy on top of basic auth.
## Gotchas
- **Hangfire serializes arguments.** Pass primitives (Guid, string, int) — not large object graphs. If you need state, look it up inside the job from the DI-resolved DbContext.
- **`FshJobActivator` creates a new scope per job.** Don't try to share state via scoped services across job invocations; each gets a clean container. Use Valkey / DB for cross-job state.
- **Stale-lock cleanup runs ~5s after host startup.** `HangfireStaleLockCleanupService` releases locks from servers that crashed without releasing. The delay avoids interfering with legitimate startup-time job acquisition.
- **Background jobs and tenant context.** Enqueuing happens inside a request scope (so `ICurrentUser` works). Execution happens on a background thread (so it doesn't). `FshJobFilter` bridges this — but if you write your own filter or skip the kit's activator, restore the tenant context manually.
## Related
- [Jobs building block](/docs/building-blocks/jobs/) — the implementation reference.
- [Billing module](/docs/modules/billing/) — `MonthlyInvoiceJob` recurring schedule.
- [Files module](/docs/modules/files/) — orphan + retention purge jobs.
- [Webhooks module](/docs/modules/webhooks/) — `[AutomaticRetry]` with exponential backoff.
---
# Caching
> HybridCache (L1 in-memory + L2 Valkey) with OpenTelemetry instrumentation, stampede protection, and tag-based invalidation.
Source: https://fullstackhero.net/docs/cross-cutting-concerns/caching/
`HybridCache` is the .NET 10 framework cache primitive. L1 is in-process memory; L2 is **Valkey** (a Redis-compatible, BSD-licensed Redis fork) — or `DistributedMemoryCache` for dev. The kit wraps it with `ObservableHybridCache` so every call emits OpenTelemetry spans plus metrics — `fsh.cache.hits`, `fsh.cache.misses`, `fsh.cache.invalidations`, and `fsh.cache.factory.duration`.
The distributed L2 store is Valkey 8, which speaks the Redis protocol (RESP), so the .NET client (`StackExchange.Redis`) and all `CachingOptions:Redis` config keys are unchanged. In Aspire-based local dev, a **RedisInsight** browser is auto-wired and connected to the Valkey instance so you can inspect keys without any manual config — see [Aspire](/docs/deployment/aspire/).
HybridCache coalesces concurrent identical requests into a single source fetch automatically. The first caller blocks on the source; the rest of the wave wait on the same task. You don't write lock code; just call `GetOrCreateAsync` and the cache does the right thing.
## How it's wired
```csharp
// Program.cs (via AddHeroPlatform when EnableCaching = true)
builder.AddHeroPlatform(o => o.EnableCaching = true);
```
`AddHeroCaching` reads `CachingOptions:Redis` from configuration. When set, L2 is `RedisCache` with a shared `IConnectionMultiplexer`. When empty, L2 falls back to `DistributedMemoryCache` (single-process; dev/test only).
## Using the cache
Inject `HybridCache` and call `GetOrCreateAsync`:
```csharp
public sealed class GetTenantConfigQueryHandler(HybridCache cache, ITenantConfigRepo repo)
: IQueryHandler
{
public ValueTask Handle(GetTenantConfigQuery q, CancellationToken ct)
=> cache.GetOrCreateAsync(
$"tenant-config:{q.TenantId}",
async (innerCt) => await repo.LoadAsync(q.TenantId, innerCt).ConfigureAwait(false),
tags: [$"tenant:{q.TenantId}"],
cancellationToken: ct);
}
```
Three things happen automatically:
1. **L1 hit** returns immediately without touching Valkey or the source.
2. **L1 miss / L2 hit** populates L1 and returns.
3. **Both miss** runs the factory, populates L1 + L2, returns.
The `tags` array enables targeted invalidation:
```csharp
// after the tenant's config changes
await cache.RemoveByTagAsync($"tenant:{tenantId}", ct).ConfigureAwait(false);
```
This evicts every cache entry tagged with that tenant id across L1 + L2.
The kit's key + tag conventions live in `CacheKeys` (Caching building block): short prefixed keys like `perm:u:{userId}`, `theme:t:{tenantId}`, `idem:t:{tenantId}:{key}`, and well-known tags — `permissions`, `themes`, `idempotency`, plus `CacheKeys.Tags.Tenant(id)` / `CacheKeys.Tags.User(id)` for scoped bulk invalidation. Follow the same shape for your own entries: tenant-scope the key where applicable, and always attach the tenant tag so tenant-wide purges catch your entries too.
## Configuration
```jsonc
{
"CachingOptions": {
"Redis": "localhost:6379",
"EnableSsl": false,
"DefaultExpiration": "01:00:00",
"DefaultLocalCacheExpiration": "00:02:00",
"MaximumKeyLength": 1024,
"MaximumPayloadBytes": 1048576
}
}
```
| Option | Default | Purpose |
|---|---|---|
| `Redis` | empty | Connection string; empty falls back to in-memory L2 |
| `EnableSsl` | null | Overrides the connection string's TLS setting; Aspire 13.x defaults Redis to TLS on the primary port, so set `false` when wiring the plain-TCP endpoint |
| `DefaultExpiration` | 1 hour | TTL applied to both L1 + L2 |
| `DefaultLocalCacheExpiration` | 2 minutes | L1-only TTL; bounds cross-node staleness after `RemoveByTag` on peers |
| `MaximumKeyLength` | 1024 | Keys longer than this are rejected |
| `MaximumPayloadBytes` | 1 MB | Larger payloads are skipped silently (warning logged) |
## Shared multiplexer
When `CachingOptions:Redis` is set, `AddHeroCaching` connects once and registers a single shared `IConnectionMultiplexer` used by:
- The **distributed cache** (`AddStackExchangeRedisCache` via a multiplexer factory).
- **Data Protection** key persistence (`DataProtection-Keys`, app name `FSH.Starter`) — so auth cookies, reset/confirmation tokens, and antiforgery survive rolling deploys across instances.
One connection pool per host for those consumers. The SignalR backplane (realtime) reads the same `CachingOptions:Redis` connection string but establishes its own connection.
## Who uses it in the kit
- **Multitenancy module** caches resolved tenants in the distributed cache (Finbuckle's `DistributedCacheStore`, 60-minute TTL) and tenant themes in HybridCache (`TenantThemeService`, with `RemoveByTagAsync` per-tenant invalidation).
- **Identity module** caches user permission sets in HybridCache (`UserPermissionService`, tag-invalidated; `RolePermissionSyncer` keeps role-permission state in sync at startup) and impersonation-grant revocation markers.
- **Realtime** uses the distributed cache for the 3-second typing-indicator throttle per (channel, user) in `AppHub`.
- **Idempotency** uses it to cache request responses by `Idempotency-Key`.
## Gotchas
- **L1 has no backplane.** After `RemoveByTagAsync` on one instance, peer instances' L1 copies aren't invalidated. The `DefaultLocalCacheExpiration` (default 2 min) bounds staleness — don't set it too high if you rely on fast cross-node invalidation.
- **Payload size cap is silent.** Entries larger than `MaximumPayloadBytes` are skipped with a warning log, not stored. Lookup will then miss every time. Log-search for "payload too large" warnings before assuming the cache is broken.
- **`DistributedMemoryCache` is per-process.** Two instances of the API can't see each other's cache. Always check `CachingOptions:Redis` is set in production.
## Related
- [Caching building block](/docs/building-blocks/caching/) — the implementation reference.
- [Idempotency](/docs/cross-cutting-concerns/idempotency/) — uses HybridCache to replay requests.
- [Multitenancy module](/docs/modules/multitenancy/) — uses HybridCache for tenant resolution.
---
# Error handling
> Global exception handler converts CustomException / NotFoundException / ForbiddenException / UnauthorizedException into RFC 9457 ProblemDetails responses.
Source: https://fullstackhero.net/docs/cross-cutting-concerns/error-handling/
Every uncaught exception in fullstackhero turns into a `ProblemDetails` response (RFC 9457). One handler, one shape, every endpoint. React clients (`clients/admin` + `clients/dashboard`) parse the shape directly so error UI is consistent across the kit.
The kit's idiom is throw-from-domain. Aggregates throw `CustomException` / `NotFoundException` from inside their methods; the handler catches at the boundary. You can layer a Result<T> pattern on top if you want, but you'd be adding ceremony — the global handler already does the HTTP translation cleanly.
## The exception hierarchy
Four exception types ship in the `Core` building block (`FSH.Framework.Core.Exceptions`). `NotFoundException`, `ForbiddenException`, and `UnauthorizedException` all derive from `CustomException`, which carries an `HttpStatusCode` and an optional `ErrorMessages` list:
| Type | HTTP Status | Use when |
|---|---|---|
| `CustomException(message, errors?, HttpStatusCode)` | Caller-specified (500 default) | Generic domain failure; pick the status |
| `NotFoundException(message)` | 404 | Resource missing |
| `ForbiddenException(message)` | 403 | Authenticated but not authorised |
| `UnauthorizedException(message)` | 401 | Not authenticated |
Plus `ValidationException` from FluentValidation (returned as 400 with the field errors).
## How the handler decides the response
`GlobalExceptionHandler` (in the Web block) is registered via `services.AddExceptionHandler()` and implements `IExceptionHandler.TryHandleAsync(...)`. The type-switch:
| Exception | Status | Notes |
|---|---|---|
| `FluentValidation.ValidationException` | 400 | Per-field `errors` map in the response |
| `CustomException` (incl. subclasses) | `e.StatusCode` | `title` = exception type name, `detail` = message, `errors` = `ErrorMessages` when present |
| `UnauthorizedAccessException` | 401 | BCL fallback |
| `KeyNotFoundException` | 404 | BCL fallback |
| `BadHttpRequestException` | Its own `StatusCode` (usually 400) | Malformed request — missing required header/param, unreadable or oversized body |
| Anything else | 500 | Generic "An unexpected error occurred" — no internals leaked |
The `BadHttpRequestException` mapping matters more than it looks: a request to an anonymous endpoint that omits a required parameter (like the `tenant` header binding) throws it, and before this mapping existed the client saw a misleading 500 instead of a 400 (issue #1245).
Every response also carries `traceId` (the OpenTelemetry trace id, falling back to `HttpContext.TraceIdentifier`) and `correlationId` (the `X-Correlation-ID` request header when present) as ProblemDetails extensions.
## The response shape
```http
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"title": "CustomException",
"status": 409,
"detail": "Cannot resolve a closed ticket.",
"instance": "/api/v1/tickets/3d2c.../resolve",
"traceId": "4a7d8e1f2c...",
"correlationId": "..."
}
```
Field-level errors from validation:
```http
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "Validation error",
"status": 400,
"detail": "One or more validation errors occurred.",
"instance": "/api/v1/identity/users/register",
"errors": {
"Email": ["Email is not a valid address.", "Email is already in use."],
"Password": ["Password must be at least 12 characters."]
},
"traceId": "4a7d8e1f2c...",
"correlationId": "..."
}
```
The `traceId` matches the OpenTelemetry trace id, so client-side error logs cross-reference cleanly with server traces.
## Throwing from the domain
The pattern is "the aggregate enforces the invariant; the handler catches the exception."
```csharp
// Aggregate
public void Resolve(string? resolutionNote)
{
if (Status == TicketStatus.Closed)
throw new CustomException("Cannot resolve a closed ticket.", null, HttpStatusCode.Conflict);
Status = TicketStatus.Resolved;
// ...
}
// Handler — no try/catch
public async ValueTask Handle(ResolveTicketCommand cmd, CancellationToken ct)
{
var ticket = await _db.Tickets.FindAsync([cmd.TicketId], ct).ConfigureAwait(false)
?? throw new NotFoundException($"Ticket {cmd.TicketId} not found.");
ticket.Resolve(cmd.ResolutionNote);
await _db.SaveChangesAsync(ct).ConfigureAwait(false);
return Unit.Value;
}
```
No try/catch in the handler. The global handler maps the Conflict `CustomException` to a 409 ProblemDetails; the `NotFoundException` becomes a 404. The endpoint's caller sees a structured response and the kit's React client renders the right toast / inline error.
## Validation errors
FluentValidation runs via the `ValidationBehavior` Mediator pipeline before the handler. A failing validator makes the behavior throw `ValidationException`; the global handler converts it into a 400 with per-field errors:
```csharp
public sealed class CreateProductCommandValidator : AbstractValidator
{
public CreateProductCommandValidator()
{
RuleFor(x => x.Sku).NotEmpty().MaximumLength(64);
RuleFor(x => x.Name).NotEmpty().MaximumLength(255);
RuleFor(x => x.PriceAmount).GreaterThanOrEqualTo(0);
// ...
}
}
```
The response carries an `errors` map keyed by field name with arrays of message strings. React clients render them inline next to the field.
## What never reaches the client
Stack traces and inner exception details are **never** included in the response — there is no "development mode" flag that adds them. The handler pushes the stack trace, title, detail, and status onto the Serilog `LogContext` instead, so the full picture lands in your logs while the client only sees the structured ProblemDetails. Unexpected exceptions (the 500 fallthrough) get a fixed generic message — internal type names and messages stay server-side.
## Anti-patterns to avoid
- **`catch { return BadRequest(); }` in handlers.** The global handler already does this; wrapping it just hides intent. Throw the right exception type from the domain.
- **`throw new Exception("...")` from a handler.** Use one of the typed exceptions. A bare `Exception` falls through to "500 Internal Server Error" with a generic message — useless for the client and harder to debug.
- **Returning `IActionResult` / `Results.X(...)` from a handler.** Handlers return DTOs. The endpoint adapts to `IResult` via the endpoint builder. Keeping handlers DTO-only means the same handler is unit-testable without HTTP context.
## Logging vs responding
The handler writes the HTTP response **and** logs the exception via `ILogger` (structured message template — path, status code, title), with the exception detail and stack trace attached through `LogContext` properties. If a customer reports "I got an error", you ask for the `traceId`, look it up in your log aggregator, and you have the full picture.
The kit's `MediatorTracingBehavior` marks the command/query span as errored and tags it with `exception.type` and `exception.message` before re-throwing, so you can graph error rates per exception type and alert on spikes.
## Related
- [Core building block](/docs/building-blocks/core/) — `CustomException` and friends.
- [Web building block](/docs/building-blocks/web/) — `GlobalExceptionHandler`, validation behavior.
- [Observability](/docs/cross-cutting-concerns/observability/) — `traceId` linking responses to server traces.
- [Vertical Slice Architecture](/docs/architecture/vertical-slice/) — the handler conventions that work with this error model.
---
# Feature flags
> Microsoft.FeatureManagement with a custom TenantFeatureFilter so flags can be enabled per tenant.
Source: https://fullstackhero.net/docs/cross-cutting-concerns/feature-flags/
`Microsoft.FeatureManagement.AspNetCore` is the kit's feature-flag system. The kit ships a custom `TenantFeatureFilter` so flags can be enabled for specific tenants — useful for staged rollouts, beta cohorts, kill switches per customer, and per-environment toggles without code changes.
A feature flag's job is to ship a risky change behind a switch you can flip if it goes wrong. Once the feature is stable, **remove the flag**. Flags that linger forever become a different (worse) kind of dead code than the alternative they were meant to replace.
## Opt in
```csharp
builder.AddHeroPlatform(o => o.EnableFeatureFlags = true);
```
`AddHeroPlatform` registers `Microsoft.FeatureManagement` services plus `TenantFeatureFilter` so per-tenant evaluation works. Feature flags are **off by default** — the shipped host (`FSH.Starter.Api/Program.cs`) doesn't enable them, so flip the toggle before declaring any flags.
## Declaring flags
Flags live in `appsettings.json` under `FeatureManagement`. Three example shapes:
```jsonc
{
"FeatureManagement": {
// 1. simple on/off
"BetaCheckoutFlow": true,
// 2. tenant-scoped — only enabled for listed tenants
"BetaProductImagesV2": {
"EnabledFor": [
{ "Name": "Tenant", "Parameters": { "AllowedTenants": ["acme", "globex"] } }
]
},
// 3. multi-filter (tenant + percentage rollout)
"NewSearchUi": {
"EnabledFor": [
{ "Name": "Tenant", "Parameters": { "AllowedTenants": ["acme"] } },
{ "Name": "Microsoft.Percentage", "Parameters": { "Value": 10 } }
]
}
}
}
```
The `Tenant` filter is the kit's `TenantFeatureFilter` (alias `Tenant`, parameter `AllowedTenants`, case-insensitive tenant-id match); the percentage filter is built into `Microsoft.FeatureManagement`. You can stack filters — they OR together by default (any filter passing enables the flag).
## Checking a flag
Inject `IFeatureManager` and call `IsEnabledAsync`:
```csharp
public sealed class GetProductByIdQueryHandler(
ICatalogDbContext db,
IFeatureManager features,
IImageEnricher images)
: IQueryHandler
{
public async ValueTask Handle(GetProductByIdQuery q, CancellationToken ct)
{
var product = await db.Products.FindAsync([q.ProductId], ct).ConfigureAwait(false);
if (await features.IsEnabledAsync("BetaProductImagesV2").ConfigureAwait(false))
return await images.EnrichV2Async(product, ct).ConfigureAwait(false);
return ProductResponse.From(product);
}
}
```
For tenant-scoped flags, `TenantFeatureFilter` resolves the tenant from the Finbuckle multi-tenant context (falling back to the `tenant` request header if the context isn't resolved yet) and matches it against `AllowedTenants`. No tenant resolvable → the filter says no. No flag definition at all → `IsEnabledAsync` returns `false`.
## Gating endpoints
For all-or-nothing endpoint gating, use the kit's `.RequireFeature(...)` extension, which attaches `FeatureGateEndpointFilter`:
```csharp
endpoints.MapGet("/catalog/products/recommended", handler)
.RequirePermission(perm)
.RequireFeature("RecommendationsEndpoint");
```
When the flag is off, the endpoint returns **404 Not Found** — to callers, a gated-off endpoint doesn't exist. Use this for shipping new endpoints behind a flag without exposing them broadly.
## Common patterns
### Staged rollout
Enable for one tenant first, then add more once you're confident:
```jsonc
{
"FeatureManagement": {
"NewBilling": {
"EnabledFor": [
{ "Name": "Tenant", "Parameters": { "AllowedTenants": ["test-tenant"] } }
]
}
}
}
```
Re-deploy with more tenants in the array as confidence grows; eventually drop the filter and set the flag to `true` globally.
### Kill switch
```jsonc
{ "FeatureManagement": { "ChatRealtimeEnabled": true } }
```
Set to `false` in an incident to instantly disable the SignalR push path without redeploying. Have the handler fall back to polling, or return a graceful "feature temporarily unavailable" response.
### Per-environment
`appsettings.Production.json` overrides `appsettings.json`. Set flags to `false` for production until they ship:
```jsonc
// appsettings.Production.json
{
"FeatureManagement": {
"BetaCheckoutFlow": false
}
}
```
## Where flag state lives
In **configuration**. The kit doesn't ship a database-backed flag store. That's deliberate: editing JSON config is cheap, auditable through git history, and doesn't require a separate management UI.
Related but separate: billing/quota plans carry an `ActiveFeatureFlags` limit (`QuotaResource.ActiveFeatureFlags` — see `QuotaOptions:Plans` and the admin plan form). That's a metering dimension for plan design; nothing currently enforces it against your `FeatureManagement` definitions.
If you outgrow JSON — you need runtime flag toggles without redeploy, or per-user (not per-tenant) flags — wire a custom `IFeatureDefinitionProvider` against your store of choice (Redis, EF Core, ConfigCat, LaunchDarkly). The kit's `TenantFeatureFilter` pattern is the template for custom filters.
## Removing a flag
When a flag has been on globally for a while and the alternative is gone:
1. Delete the flag definition from `appsettings.json`.
2. Remove the `IFeatureManager.IsEnabledAsync` call in the handler — collapse the conditional to the always-on branch.
3. Delete the dead alternative path.
Don't skip step 3. Lingering "old" branches are technical debt and a security risk (un-tested code that might still be reachable).
## Related
- [Multitenancy module](/docs/modules/multitenancy/) — the tenant resolution that `TenantFeatureFilter` reads.
- [Production checklist](/docs/security/) — flag hygiene before a major release.
- [Web building block](/docs/building-blocks/web/) — `EnableFeatureFlags` toggle.
---
# Health checks
> Liveness + readiness endpoints — per-module database checks, Valkey, Hangfire, tenant migrations — for Kubernetes / Docker Compose / load balancer probes.
Source: https://fullstackhero.net/docs/cross-cutting-concerns/health-checks/
Two health endpoints ship out of the box: `GET /health/live` for liveness, `GET /health/ready` for readiness. Both are anonymous and exempt from rate limiting. The kit registers checks for every dependency the host needs to function — one database check per module `DbContext`, Valkey (a Redis-compatible, BSD-licensed Redis fork) when caching is enabled, Hangfire when jobs are enabled, and a per-tenant migration check that reports whether every tenant DB is on the latest schema.
Liveness asks "is the process alive?" — restart if it fails. Readiness asks "is the process ready to take traffic?" — stop sending requests if it fails. The kit's `/health/live` runs **zero** dependency checks (just proves the process responds); `/health/ready` runs **every** registered check.
## What's checked
| Check | Source | When registered |
|---|---|---|
| `self` | `AddHeroPlatform` | Always |
| `db:{module}` (e.g. `db:identity`, `db:catalog`, `db:billing`…) | `AddDbContextCheck` in each module's registration | One per module DbContext |
| `redis` | Kit's `RedisHealthCheck` | When `EnableCaching = true` **and** `CachingOptions:Redis` is set |
| `hangfire` | Kit's `HangfireHealthCheck` | When `EnableJobs = true` |
| `db:tenants-migrations` | Kit's `TenantMigrationsHealthCheck` (Multitenancy module) | Always (with the Multitenancy module) |
There is no storage/MinIO health check — blob storage failures surface through the Files module's own error handling, not readiness.
## How to wire probes
### Kubernetes
```yaml
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
```
### Docker Compose
```yaml
services:
api:
image: fsh-api:latest
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
interval: 10s
timeout: 5s
retries: 3
```
### Cloud load balancers
Point them at `/health/ready` — they'll only route traffic to instances that report ready.
## Response shape
The kit serializes its own compact shape (not the default ASP.NET Core health JSON):
```http
GET /health/live HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json
{ "status": "Healthy", "results": [] }
```
```http
GET /health/ready HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "Healthy",
"results": [
{ "name": "self", "status": "Healthy", "description": null, "durationMs": 0.01 },
{ "name": "db:identity", "status": "Healthy", "description": null, "durationMs": 12.3 },
{ "name": "db:catalog", "status": "Healthy", "description": null, "durationMs": 8.7 },
{ "name": "redis", "status": "Healthy", "description": null, "durationMs": 1.9 },
{ "name": "db:tenants-migrations", "status": "Healthy", "description": "All tenants are at the head migration.", "durationMs": 41.0, "details": { /* per-tenant data */ } }
]
}
```
When any check fails, the overall response is HTTP 503 — with the **same body shape**, so operators can see exactly which check failed while probe consumers key off the status code alone.
## Adding your own check
Implement `IHealthCheck` and register it during module startup:
```csharp
public sealed class CustomerApiHealthCheck(HttpClient http) : IHealthCheck
{
public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken ct)
{
try
{
using var resp = await http.GetAsync("/health", ct).ConfigureAwait(false);
return resp.IsSuccessStatusCode
? HealthCheckResult.Healthy()
: HealthCheckResult.Degraded($"Upstream returned {(int)resp.StatusCode}");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Upstream unreachable", ex);
}
}
}
// register in the module's ConfigureServices
services.AddHealthChecks().AddCheck("customer-api");
```
Every check registered with `AddHealthChecks()` automatically participates in `/health/ready` — liveness runs no checks, so there's nothing to tag or filter. Keep slow checks (cross-region pings, third-party APIs) out of the registry or make them cheap; readiness latency is probe latency.
## Tenant migrations check
`TenantMigrationsHealthCheck` is unusual — it iterates every tenant, sets that tenant's Finbuckle context, and asks EF Core for pending migrations. Per-tenant detail lands in the check's `data`:
```json
{
"name": "db:tenants-migrations",
"status": "Unhealthy",
"description": "Tenant schema is not at head — pending migrations for tenant(s): acme. Run FSH.Starter.DbMigrator to apply pending migrations.",
"details": {
"acme": { "name": "Acme", "isActive": true, "hasPendingMigrations": true, "pendingMigrations": ["20260601_AddX"] }
}
}
```
A tenant with pending migrations (or one whose probe throws) makes the check `Unhealthy`, which makes `/health/ready` return 503 — Kubernetes keeps the pod out of rotation until the standalone `FSH.Starter.DbMigrator` catches the schema up. That's deliberate: the DB is never migrated at API startup, so a pod running newer code against an older schema must not take traffic.
## What health checks don't tell you
- **Per-endpoint health** — `/health/ready` reports infrastructure state, not "is this specific endpoint working." Use synthetic monitoring (probing real endpoints with known payloads) for that.
- **Latency** — health is binary; latency is a spectrum. Use the OpenTelemetry HTTP server metrics for latency SLOs.
- **Partial tenant outages** — the migrations check covers schema drift, but a single tenant's connectivity problem in a tenant-per-database setup needs its own aggregation if your SLA covers per-tenant availability.
## Related
- [Observability](/docs/cross-cutting-concerns/observability/) — for latency + error-rate signals.
- [Deployment guide](/docs/deployment/) — how the probes are wired in the kit's docker-compose and Kubernetes manifests.
- [Multitenancy module](/docs/modules/multitenancy/) — owns the per-tenant migration check.
---
# HTTP resilience
> Polly v8 via Microsoft.Extensions.Http.Resilience — retry, circuit breaker, timeouts — opt-in per HttpClient with the kit's AddHeroResilience extension.
Source: https://fullstackhero.net/docs/cross-cutting-concerns/http-resilience/
`Microsoft.Extensions.Http.Resilience` (Polly v8 under the hood) backs the kit's outbound HTTP calls. The Web block ships an `AddHeroResilience` extension that attaches the standard resilience pipeline — **retry with backoff** on transient failures, a **circuit breaker** to fail fast when a downstream is consistently broken, and **total + per-attempt timeouts** to prevent hung requests from holding threadpool slots — to any `IHttpClientBuilder`, configured from one appsettings section.
You shouldn't see `for (var i = 0; i < 3; i++) { try { ... } catch { ... } }` anywhere in the kit. Register a named client and chain `.AddHeroResilience(builder.Configuration)` — the pipeline does retry, breaking, and timeouts for you, with config-tunable knobs.
## How it's wired
Resilience is **opt-in per client**, not global. The Webhooks module is the shipped example:
```csharp
// WebhooksModule.ConfigureServices
builder.Services.AddHttpClient("Webhooks")
.AddHeroResilience(builder.Configuration);
```
`AddHeroResilience` reads the `HttpResilienceOptions` section and calls `AddStandardResilienceHandler` with those values. When you add your own outbound integration, do the same — name the client, chain the extension.
## What the standard pipeline does
```
[Outer] Total request timeout (30s default)
↓
[Retry] Up to 3 attempts on transient errors with backoff
↓
[Breaker] Circuit breaker — opens at 50% failure ratio, 5s break
↓
[Attempt] Per-attempt timeout (10s default)
↓
HttpClient.SendAsync
```
## Configuration
```jsonc
{
"HttpResilienceOptions": {
"Enabled": true, // default — false skips the handler entirely
"MaxRetryAttempts": 3,
"MedianFirstRetryDelay": "00:00:01", // first retry delay; backoff grows from here
"TotalTimeout": "00:00:30", // whole request incl. retries
"AttemptTimeout": "00:00:10", // each individual attempt
"CircuitBreakerBreakDuration": "00:00:05",
"CircuitBreakerFailureRatio": 0.5,
"CircuitBreakerMinimumThroughput": 10
}
}
```
These are the code defaults — the shipped `appsettings.json` doesn't carry the section, so you only add it when tuning. One section configures every client that opted in via `AddHeroResilience`; for genuinely different upstreams (a slow third-party API), register that client with its own `AddStandardResilienceHandler(opts => ...)` lambda instead.
## How resilience integrates with `[AutomaticRetry]`
Hangfire's `[AutomaticRetry]` and the HTTP resilience pipeline are **two separate retry layers**. The kit uses both, deliberately:
- **HTTP-layer retry** handles transient network blips (5xx, timeouts) within a single job attempt — fast, in-process, no job queue churn.
- **Job-layer retry** handles longer outages — minutes-to-hours-scale backoff while the Hangfire job stays in the queue.
For the Webhooks module: each delivery attempt runs through the Polly pipeline (up to 3 in-process retries, seconds apart). If the attempt still fails, the Hangfire job fails and `[AutomaticRetry(Attempts = 4, DelaysInSeconds = [30, 120, 600, 3600])]` re-runs it — up to 5 job-level attempts spread over ~1 h 12 min.
## When NOT to retry
The standard resilience handler retries on **transient** failures only:
- HTTP 408 Request Timeout
- HTTP 429 Too Many Requests
- HTTP 500+ server errors
- Connection / timeout exceptions (`HttpRequestException`, attempt timeouts)
It does **not** retry permanent client errors — 400, 401, 403, 404, 405, 409 — because retrying won't change the answer. For specific 4xx codes that *should* be treated as transient (rare — e.g. an API that returns 423 Locked on transient contention), configure `ShouldHandle` with a custom predicate on a per-client `AddStandardResilienceHandler` registration.
## Circuit breaker
The breaker opens when the failure ratio crosses `CircuitBreakerFailureRatio` (50%) over at least `CircuitBreakerMinimumThroughput` (10) requests. While open, requests fail fast with `BrokenCircuitException` instead of waiting for the timeout. After `CircuitBreakerBreakDuration` (5 s), the breaker half-opens — a trial request decides whether to close (resume normal traffic) or stay open (downstream still broken).
This is what stops a flaky downstream from cascading into your threadpool. Watch breaker open / close events in your traces; opens that don't half-close mean the downstream is genuinely down and needs human attention.
## Hedging (for read-only calls)
The kit doesn't ship a hedging pipeline, but `Microsoft.Extensions.Http.Resilience` includes one if you need it. Hedging fires parallel requests; the first response wins, the rest are cancelled. Trades load for latency:
```csharp
services.AddHttpClient("LatencySensitiveReads")
.AddStandardHedgingHandler(opts =>
{
opts.Hedging.MaxHedgedAttempts = 2; // up to 2 parallel requests
opts.Hedging.Delay = TimeSpan.FromMilliseconds(200); // wait 200ms before firing the hedge
});
```
Don't enable it globally — it multiplies the downstream's load. Reserve it for high-percentile-sensitive reads against upstreams that can absorb the extra volume.
## Gotchas
- **Configure timeouts on the pipeline, not the client.** The pipeline's `TotalTimeout` is the effective bound; a mismatched `HttpClient.Timeout` just adds a second, confusing limit.
- **Retry + non-idempotent requests = duplicate creates.** If you POST and retry on 502, you might create two resources. Combine with [Idempotency-Key](/docs/cross-cutting-concerns/idempotency/) on every retried POST.
- **Look at `InnerException`.** Polly's timeout/breaker exceptions wrap the underlying transport failure.
- **OpenTelemetry sees each attempt as its own HTTP client span.** A request that succeeds on the third try shows three spans in the trace. This is correct; ignore the noise in dashboards.
## Related
- [Idempotency](/docs/cross-cutting-concerns/idempotency/) — required when retrying non-idempotent operations.
- [Webhooks module](/docs/modules/webhooks/) — the shipped consumer.
- [Background jobs](/docs/cross-cutting-concerns/background-jobs/) — Hangfire `[AutomaticRetry]` complements HTTP-layer retry.
---
# Idempotency
> Idempotency-Key header support with HybridCache-backed replay protection across instances.
Source: https://fullstackhero.net/docs/cross-cutting-concerns/idempotency/
Network is unreliable. A `POST /orders` request might submit, time out before the response arrives, and get retried. Without idempotency, the retry creates a second order. With it, the retry returns the same response as the first.
The kit ships an `Idempotency-Key` header convention. Callers send a fresh key with every request that mutates state; the kit caches the response (status + body) keyed on `(tenant, key)` for a configurable window. Duplicate requests within the window return the cached response without re-executing the handler.
Checkout, send-message, create-resource flows. Skip it on flows that are naturally idempotent (PUT-with-id, PATCH-with-version) where the caller controls the determinism. In the kit, the Billing and Catalog modules already apply it to their mutating endpoints — `CreatePlanEndpoint`, `MarkInvoicePaidEndpoint`, `CreateBrandEndpoint`, and friends.
## Opt an endpoint in
The Web block ships a `.WithIdempotency()` extension on `RouteHandlerBuilder`:
```csharp
endpoints.MapPost("/orders", handler)
.RequirePermission(perm)
.WithIdempotency();
```
That's the whole opt-in. The kit's `IdempotencyEndpointFilter` (an `IEndpointFilter`) wraps the handler:
1. **Before**: read the `Idempotency-Key` header. If present, build the cache key `idem:t:{tenant}:{key}` (tenant from the caller's `tenant` claim, `"global"` when absent) and probe the cache.
2. **If cached**: write the cached status + body, set an `Idempotency-Replayed: true` response header, and skip the handler entirely.
3. **If not**: invoke the handler, then store the serialized result + status code under the cache key with a TTL.
If no header is sent, the filter is a no-op — the endpoint behaves like an ordinary one. Keys longer than `MaxKeyLength` are rejected with a 400 before the handler runs.
The probe reads through `IDistributedCache` directly (a real get-or-null, bypassing L1 — replays are rare so L1 warmth has little value); the write goes through `HybridCache.SetAsync` with tags (`idempotency` + the tenant tag) so tag-based purges work. Caching the response is **best-effort**: if the store write fails, the filter logs a warning and the request still succeeds.
## Configuration
```jsonc
{
"IdempotencyOptions": {
"HeaderName": "Idempotency-Key", // default
"DefaultTtl": "1.00:00:00", // 24 hours (default)
"MaxKeyLength": 128 // default
}
}
```
Idempotency is **on by default** in `FshPlatformOptions` (`EnableIdempotency = true`); `AddHeroPlatform` binds the options. The replay store is HybridCache, so the L2 (Valkey) backing means cached responses survive across instances. Without Valkey you get per-instance idempotency, which is OK for dev but not for multi-instance production.
## What clients send
```http
POST /api/v1/orders HTTP/1.1
Authorization: Bearer
tenant: acme
Idempotency-Key: 7f3d9a2c-1b8e-4f5a-9c0d-2e8f6b1a3d4c
Content-Type: application/json
{ "items": [...] }
```
The key should be a fresh UUID per *logical* request:
- A retry of the same request uses the **same** key — gets the cached response (and the `Idempotency-Replayed: true` header, so clients can tell).
- A different request (even from the same client) uses a **fresh** key — gets a fresh response.
Most HTTP client libraries can generate keys automatically; for `HttpClientFactory` consumers, a `DelegatingHandler` is a clean place to do it.
## What it doesn't do
- **It doesn't dedupe content.** If the client sends two requests with **different** keys and identical bodies, both create resources. Idempotency keys are per-request-attempt identifiers, not content hashes. (Use a content hash + lookup if you want content dedupe — but it's a different feature.)
- **It doesn't span the request boundary forever.** After `DefaultTtl` expires, a replayed request runs again. Set the TTL to whatever is realistic for your client retry strategy — 24 h is the default; longer is reasonable for batch / async flows.
- **It doesn't catch partial failures.** If the handler runs, mutates state, then crashes before the response is captured, the cache won't have an entry. The retry runs again. Combine idempotency with idempotent domain operations for true safety.
## Domain-level idempotency
The kit's domain already has several naturally-idempotent operations:
- `Notification.MarkRead()` — `ReadAtUtc ??= now`; second call is a no-op.
- `Ticket.Reopen()` — guarded against illegal source states; second call from a valid state is harmless.
- `WebhookSubscription` deactivation — sets `IsActive = false`; idempotent.
- Find-or-create DM channels — `DirectKey` uniqueness makes "find or create" race-safe.
Layer `Idempotency-Key` on top of these for full retry safety across both transport and domain failure modes.
## Gotchas
- **The cache key includes tenant**, so two tenants using the same idempotency key get separate responses. This is correct; tenants are independent.
- **The cache key does NOT include the route.** Reusing one key across two different idempotent endpoints within the same tenant replays the first endpoint's response on the second. Always generate a fresh key per logical request — never share keys across operations.
- **Cache miss after restart is normal.** If Valkey isn't configured, the in-memory L2 starts empty after a restart — replayed requests run again. Always use Valkey in production.
## Related
- [Caching](/docs/cross-cutting-concerns/caching/) — the HybridCache that backs the replay store.
- [HTTP resilience](/docs/cross-cutting-concerns/http-resilience/) — caller-side retry policy that needs idempotency to be safe.
- [Web building block](/docs/building-blocks/web/) — the filter implementation.
---
# Observability
> Serilog structured logging + OpenTelemetry traces and metrics over OTLP — with Mediator, EF Core, Npgsql, Redis, and Hangfire instrumentation.
Source: https://fullstackhero.net/docs/cross-cutting-concerns/observability/
Observability is on by default in fullstackhero (`EnableOpenTelemetry = true` in `FshPlatformOptions`). Every HTTP request is traced; every Mediator command is traced; every EF Core / Npgsql query is traced; every Hangfire job is traced. Structured logs flow through Serilog with an HTTP-context enricher, and traces + metrics + logs cross the OTLP exporter to whatever collector you point it at (Aspire dashboard locally; Honeycomb / Datadog / Grafana / Tempo / Loki in prod).
A Mediator pipeline behavior (`MediatorTracingBehavior`) means every `mediator.Send(new MyCommand(...))` opens an `Activity` automatically. Same for HTTP, EF, and Hangfire — handled by the standard instrumentation packages plus two kit-specific hooks. The kit's job is wiring; you focus on what to log explicitly.
## What gets observed without you doing anything
| Source | Instrumentation | Telemetry |
|---|---|---|
| ASP.NET Core | `AddAspNetCoreInstrumentation` | Request traces (health paths filtered out) + server metrics |
| HttpClient | `AddHttpClientInstrumentation` | Outbound HTTP traces + metrics |
| EF Core | `AddEntityFrameworkCoreInstrumentation` | Query traces |
| Postgres | `AddNpgsql` / `AddNpgsqlInstrumentation` | Driver-level traces + metrics |
| Valkey | `AddRedisInstrumentation` | Cache call traces (command text suppressed when `Data:FilterRedisCommands` is on) |
| .NET runtime | `AddRuntimeInstrumentation` | GC, threadpool, exception metrics |
| Mediator | `MediatorTracingBehavior` (kit-specific) | A span per command/query with `mediator.request_type`, error status + `exception.type`/`exception.message` on failure |
| Hangfire | `HangfireTelemetryFilter` (kit-specific, source `FSH.Hangfire`) | A span per job execution with `hangfire.job_id`, `hangfire.job_type`, `hangfire.job_method` |
| HybridCache | `ObservableHybridCache` decorator (source/meter `FSH.Caching`) | Spans per cache op + hit/miss/invalidation counters, factory duration |
Requests to `/health*` and `/alive` are excluded from request tracing so probes don't drown your trace store.
## Logging — Serilog with enrichers
`AddHeroLogging` configures Serilog (reading the `Serilog` section of appsettings) and adds the kit's `HttpRequestContextEnricher`, which stamps every log event written during a request with:
- `RequestMethod`, `RequestPath`, `UserAgent`
- `UserId`, `Tenant`, `UserEmail` — when the request is authenticated
The shipped `Serilog` config adds `FromLogContext`, `WithMachineName`, `WithThreadId`, `WithCorrelationId`, `WithProcessId`, and `WithProcessName` enrichers, writes to the console, and quiets the chatty framework categories (`Microsoft`, `Hangfire`, `Finbuckle`, EF Core) to warning-or-worse.
```csharp
_logger.LogInformation("Created product {Sku} for tenant {TenantId}", product.Sku, tenantId);
```
Structured logging only — message templates or `[LoggerMessage]` source generators, never string interpolation (the build treats analyzer warnings as errors).
**Log export over OTLP is automatic.** Serilog owns the logging pipeline, so the kit exports logs from *inside* Serilog via the OpenTelemetry sink — added automatically when either the `OTEL_EXPORTER_OTLP_ENDPOINT` env var is present (Aspire injects it) or `OpenTelemetryOptions:Exporter:Otlp:Enabled` is true with an endpoint configured. The sink reuses the same `service.name` as traces/metrics so the dashboard groups all three signals under one resource.
## Tracing — what shows up in traces
A typical request trace:
```
HTTP POST /api/v1/catalog/products [12ms]
├─ Mediator CreateProductCommand [11ms]
│ ├─ EF: SELECT ... FROM "Brands" ... [2ms]
│ ├─ EF: SELECT ... FROM "Categories" ... [1ms]
│ └─ EF: INSERT INTO "Products" ... [4ms]
└─ HTTP response 201 Created
```
Every step is a span with start time, duration, and structured attributes. Trace context propagates to outbound HTTP calls (webhook deliveries, third-party APIs) so a single trace id follows the request across processes. By default `Data:FilterEfStatements` / `Data:FilterRedisCommands` suppress raw statement text to keep PII and noise out of spans.
## Metrics — what's measured
- **Runtime metrics** — GC counts, threadpool size, exception count, allocation rate.
- **HTTP server metrics** — `http.server.duration` histogram with kit-tuned buckets (10 ms → 5 s by default; override via `Http:Histograms:BucketBoundaries`).
- **Cache metrics** — `fsh.cache.hits`, `fsh.cache.misses`, `fsh.cache.invalidations` (counters) and `fsh.cache.factory.duration` (histogram, ms) from `ObservableHybridCache`.
- **Module metrics** — the host opts module meters in via `Metrics:MeterNames` (shipped: `FSH.Modules.Identity`, `FSH.Modules.Multitenancy`, `FSH.Modules.Auditing`).
- **Custom metrics** — create a `Meter` and add its name to `Metrics:MeterNames`; the OTel registration picks it up.
The metric export interval defaults to 10 s (instead of the SDK's 60 s) so dashboards populate quickly after a restart; set `OTEL_METRIC_EXPORT_INTERVAL` to override.
## Configuration
The section is `OpenTelemetryOptions`:
```jsonc
{
"OpenTelemetryOptions": {
"Enabled": true,
"Tracing": { "Enabled": true },
"Metrics": {
"Enabled": true,
"MeterNames": [ "FSH.Modules.Identity", "FSH.Modules.Multitenancy", "FSH.Modules.Auditing" ]
},
"Exporter": {
"Otlp": {
"Enabled": false, // dev default — Aspire injects its own endpoint via env vars
"Endpoint": "http://localhost:4317",
"Protocol": "grpc" // or "http/protobuf"
}
},
"Jobs": { "Enabled": true }, // Hangfire spans
"Mediator": { "Enabled": true }, // MediatorTracingBehavior
"Http": { "Histograms": { "Enabled": true } },
"Data": {
"FilterEfStatements": true, // keep SQL text out of spans
"FilterRedisCommands": true // keep Redis command text out of spans
}
}
}
```
Two things are deliberately **not** config keys:
- **Service name** — resolved from the `OTEL_SERVICE_NAME` env var (Aspire and most collectors inject it) with the application name as fallback, so the kit's telemetry groups under the same resource the orchestrator already knows.
- **The Aspire endpoint** — when `OTEL_EXPORTER_OTLP_ENDPOINT` is present in the environment, the kit exports to it *even if* `Exporter:Otlp:Enabled` is false, and lets the SDK read endpoint/protocol/headers from the standard `OTEL_EXPORTER_OTLP_*` env vars. That's how traces show up live in the Aspire dashboard with zero config.
## What to add yourself
The kit ships **infrastructure tracing**. You add **business tracing** — spans for the long-running steps your handlers do that aren't already covered:
```csharp
public sealed class GenerateInvoicesCommandHandler(ActivitySource source /* , ... */)
: ICommandHandler
{
public async ValueTask Handle(GenerateInvoicesCommand cmd, CancellationToken ct)
{
using var activity = source.StartActivity("GenerateInvoicesForPeriod");
activity?.SetTag("period.year", cmd.PeriodYear);
activity?.SetTag("period.month", cmd.PeriodMonth);
// ...
}
}
```
The kit registers a shared `ActivitySource` (named after the application) in DI and subscribes the tracer to it — inject it rather than newing your own, or add your custom source name in the Web block's tracing registration.
Custom counters for domain-specific metrics ("invoices issued per period", "webhooks delivered per tenant") give you product dashboards out of the box — register the meter name in `Metrics:MeterNames`.
## Related
- [Auditing module](/docs/modules/auditing/) — different concern (compliance/forensic), but uses the same trace/correlation ids.
- [Background jobs](/docs/cross-cutting-concerns/background-jobs/) — Hangfire telemetry filter.
- [Web building block](/docs/building-blocks/web/) — `AddHeroLogging` + `AddHeroOpenTelemetry`.
---
# Rate limiting
> ASP.NET Core RateLimiting with chained per-tenant / per-user / per-IP global limiters plus an "auth" policy on login, refresh, password and registration endpoints.
Source: https://fullstackhero.net/docs/cross-cutting-concerns/rate-limiting/
`Microsoft.AspNetCore.RateLimiting` lives in the Web block (`BuildingBlocks/Web/RateLimiting/`). The kit ships **two layers**:
1. A **global limiter** — three chained fixed-window limiters partitioned per **tenant**, per **user**, and per **IP** — applied to every request when rate limiting is enabled.
2. A named **`auth` policy** that throttles authentication-flow endpoints — login, refresh, forgot-password, reset-password, confirm-email, resend-confirmation, self-register — for brute-force protection where it matters most.
Rate limiting protects against abuse — bursts of requests from a small number of callers. Quotas (the Quota building block) enforce per-tenant resource budgets across a calendar month. Different concerns, different mechanisms; the kit ships both.
## How it's configured
Everything binds from the `RateLimitingOptions` section. These are the shipped values (also the code defaults):
```jsonc
{
"RateLimitingOptions": {
"Enabled": false, // false in appsettings.json (dev); true in appsettings.Production.json
"Tenant": { "PermitLimit": 1000, "WindowSeconds": 60, "QueueLimit": 0 },
"User": { "PermitLimit": 200, "WindowSeconds": 60, "QueueLimit": 0 },
"Ip": { "PermitLimit": 300, "WindowSeconds": 60, "QueueLimit": 0 },
"Auth": { "PermitLimit": 10, "WindowSeconds": 60, "QueueLimit": 0 }
}
}
```
All four buckets are fixed-window limiters with no queueing (`QueueLimit: 0`) — excess requests get an immediate 429. Rate limiting is **off in local dev** (`appsettings.json`) and **on in production** (`appsettings.Production.json`).
## The global limiter
When enabled, `AddHeroRateLimiting` builds a chained `GlobalLimiter` (`PartitionedRateLimiter.CreateChained`) — a request must pass **all three** dimensions:
| Dimension | Partition key | Skipped when |
|---|---|---|
| Tenant | `tenant:{tenant claim}` | No `tenant` claim (anonymous) |
| User | `user:{NameIdentifier claim}` | Not authenticated |
| IP | `ip:{RemoteIpAddress}` | No resolvable remote IP |
Health probe paths (`/health`, `/healthz`, `/ready`, `/live`) are exempt from all three, so aggressive orchestrator probes never get throttled. The kit's health endpoints also call `.DisableRateLimiting()` on their route group.
The tenant bucket is the SaaS guardrail: one noisy tenant burns its own 1000-requests-per-minute budget without starving the others.
## The auth policy
The `auth` policy partitions by **user id when authenticated, otherwise by IP**, at 10 requests per minute. The Identity module attaches it with `.RequireRateLimiting("auth")`:
| Endpoint | Why |
|---|---|
| `POST /api/v1/identity/token/issue` | Login attempts |
| `POST /api/v1/identity/token/refresh` | Token refresh attempts |
| `POST /api/v1/identity/forgot-password` | Password reset request abuse |
| `POST /api/v1/identity/reset-password` | Password reset attempts |
| `GET /api/v1/identity/confirm-email` | Email confirmation attempts |
| `POST /api/v1/identity/users/{id}/resend-confirmation-email` | Confirmation mail flooding |
| `POST /api/v1/identity/self-register` | Self-registration abuse |
```csharp
group.MapGenerateTokenEndpoint().AllowAnonymous().RequireRateLimiting("auth");
```
`AuthRateLimitWiringTests` (in `src/Tests/Integration.Tests/Tests/Authentication/`) verifies every endpoint that should carry the policy actually does. Run it after touching the Identity module's endpoint registrations.
Reuse the same policy on your own sensitive anonymous endpoints — anything a bot can hammer without credentials is a candidate:
```csharp
endpoints.MapPost("/api/v1/contact", handler)
.AllowAnonymous()
.RequireRateLimiting("auth");
```
Additional named policies aren't config-driven — they're registered in code inside `AddHeroRateLimiting` (the Web building block, which is protected; see `buildingblocks-protection.md` before changing it).
## What a rejected request sees
The limiter rejects with HTTP 429 and a `ProblemDetails` body (`application/problem+json`):
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 42
Content-Type: application/problem+json
{
"type": "https://datatracker.ietf.org/doc/html/rfc6585#section-4",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded. Please retry later.",
"instance": "/api/v1/identity/token/issue",
"traceId": "4a7d8e1f2c...",
"correlationId": "..."
}
```
`Retry-After` (seconds) is included when the limiter can compute it; `traceId` and `correlationId` cross-reference server logs and traces.
## What rate limiting doesn't protect against
- **Distributed brute force** — 10,000 IPs sending one request each per minute get through every per-IP limit. Combine with credential-stuffing detection, MFA, and account lockout (the kit ships lockout via ASP.NET Identity — 15-minute default lockout window).
- **Slow-rate attacks** — an attacker sending 1 request per 10 seconds from one IP never hits a 10/min limit but might still be malicious. Combine with anomaly detection on auth-failure rates.
- **Application-layer DDoS** — rate limiting helps but isn't a complete defence. Use a CDN / WAF in front (Cloudflare, AWS WAF, Azure Front Door) for layer-7 protection.
## Watching for false positives
When a legitimate user hits the limit, they see 429 + `Retry-After`. In production, log every 429 with the source partition and endpoint:
- A single IP hitting 429 repeatedly on login → likely brute force.
- Many users hitting 429 on the same legitimate endpoint → your limit is too low.
- One user hitting 429 on refresh → their client may be misconfigured (no token caching, retrying too aggressively).
Set up an alert on "429 rate > X% of requests" so you catch tuning issues early.
## Related
- [Security overview](/docs/security/) — broader auth + abuse protection.
- [Quota building block](/docs/building-blocks/quota/) — per-tenant resource budgets (different concern).
- [HTTP resilience](/docs/cross-cutting-concerns/http-resilience/) — caller-side back-off when receiving 429.
---
# Realtime
> SignalR with a Valkey backplane, group conventions for users + channels, post-SaveChanges broadcast pattern, and distributed-cache typing throttle.
Source: https://fullstackhero.net/docs/cross-cutting-concerns/realtime/
The kit's realtime surface is a single SignalR hub — `AppHub` in `BuildingBlocks/Web/Realtime/`, mapped at `/api/v1/realtime/hub` — serving every realtime concern. On connect, every user joins their personal `user:{userId}` group, their `tenant:{tenantId}` group, plus every `channel:{channelId}` they belong to. Handlers broadcast via `IHubContext` after `SaveChanges` commits; the **Valkey** (a Redis-compatible, BSD-licensed Redis fork) backplane fans the broadcast across instances.
A second hub would mean a second auth pipeline, a second backplane configuration, a second pile of client-side connection state. Keep all realtime traffic on `AppHub`; identify event types via the SignalR method name (`ChatMessageCreated`, `NotificationCreated`, `TicketResolved`).
## Opt in
```csharp
builder.AddHeroPlatform(o => o.EnableRealtime = true);
app.UseHeroPlatform(p => p.MapRealtime = true);
```
`AddHeroRealtime` registers SignalR; if `CachingOptions:Redis` is set, it also calls `AddStackExchangeRedis(...)` (channel prefix `fsh-signalr`) so cross-instance message fan-out works. Without Valkey, every instance broadcasts only to its own connected clients — fine for dev, not for multi-instance production. `MapRealtime` maps the hub at `/api/v1/realtime/hub` plus a presence snapshot endpoint at `GET /api/v1/realtime/presence?userIds=a,b,c` that clients poll for initial online status.
## Group conventions
When a user's `HubConnection` lands at `/api/v1/realtime/hub`, the hub's `OnConnectedAsync` joins:
1. `user:{userId}` — for events targeted at this user (notifications, DM events, ticket-assigned).
2. `tenant:{tenantId}` — scopes tenant-wide broadcasts (like `PresenceChanged`) so one tenant's connect/disconnect churn never fans out to other tenants.
3. `channel:{channelId}` for every channel the user belongs to (the kit's `IUserChannelLookup` returns the list).
The result: broadcasting to `Clients.Group($"user:{userId}")` reaches every device that user is signed in on; broadcasting to `Clients.Group($"channel:{channelId}")` reaches every member of the channel.
Connect-time joins only cover channels that existed at connect time. A channel created — or a membership granted — *after* the socket is live is covered by the `JoinChannel(channelId)` hub method: clients call it when they open a conversation and on reconnect. It's membership-checked and idempotent, so calling it freely is safe.
For domain-specific groups (e.g. "everyone watching this dashboard"), invent a stable group name (`dashboard:{tenantId}:{dashboardId}`) and call `Groups.AddToGroupAsync` from a hub method or post-connect logic.
## Broadcasting from a handler
The kit's pattern is "save changes, then broadcast":
```csharp
public sealed class SendMessageCommandHandler(
IChatDbContext db,
IHubContext hub,
ICurrentUser current)
: ICommandHandler
{
public async ValueTask Handle(SendMessageCommand cmd, CancellationToken ct)
{
var message = Message.Create(/* ... */);
db.Messages.Add(message);
await db.SaveChangesAsync(ct).ConfigureAwait(false); // first commit, then broadcast
var dto = MessageDto.From(message);
await hub.Clients.Group($"channel:{cmd.ChannelId}")
.SendAsync("ChatMessageCreated", dto, ct).ConfigureAwait(false);
return dto;
}
}
```
The order matters: if you broadcast before SaveChanges and the commit fails, you've leaked a phantom event to clients. Always commit first, broadcast second.
## Typing indicator with throttle
Chat-style typing indicators flood the wire if you broadcast every keystroke. The kit's pattern is a **3-second distributed-cache throttle** per (channel, user):
```csharp
// AppHub.cs (simplified)
public async Task Typing(Guid channelId)
{
var userId = GetUserId();
if (!await _membership.IsMemberAsync(channelId, userId, Context.ConnectionAborted).ConfigureAwait(false))
return;
var key = $"typing:{channelId}:{userId}";
if (!string.IsNullOrEmpty(await _cache.GetStringAsync(key, Context.ConnectionAborted).ConfigureAwait(false)))
return; // throttled
await _cache.SetStringAsync(key, "1",
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(3) },
Context.ConnectionAborted).ConfigureAwait(false);
await Clients.OthersInGroup($"channel:{channelId}")
.SendAsync("ChatTypingStarted", new { channelId, userId }, Context.ConnectionAborted)
.ConfigureAwait(false);
}
```
The `IDistributedCache` marker key expires after 3 seconds, so the broadcast fires at most once per (channel, user) per window. With Valkey, the throttle is cluster-wide — chatty clients can't bypass it by reconnecting to a different node. Note `OthersInGroup` — the typist doesn't need their own indicator.
## Authenticating SignalR connections
Browsers' native `WebSocket` API doesn't accept `Authorization` headers on the initial handshake. SignalR's JS client routes around this by accepting an `accessTokenFactory`:
```ts
const conn = new HubConnectionBuilder()
.withUrl('/api/v1/realtime/hub', { accessTokenFactory: () => getAccessToken() })
.withAutomaticReconnect()
.build();
```
The factory returns the JWT; the SignalR client appends it as `?access_token=...`. The kit's `ConfigureJwtBearerOptions` has an `OnMessageReceived` handler that accepts `?access_token=` only on a narrow path allow-list — `/api/v1/realtime/hub` and `/notifications` — so query-string tokens can't leak into ordinary endpoints. The standard JWT pipeline validates the token from there.
## Reading the user from the hub
`AppHub` reads identity from `Context.User`, not from a constructor-injected `ICurrentUser`. The reason: `ICurrentUser` depends on `IHttpContextAccessor`, and the originating `HttpContext` from the SignalR negotiate request is not pinned to subsequent hub-method invocations. Read claims directly (this is the hub's actual helper):
```csharp
private string? GetUserId()
{
var user = Context.User;
if (user?.Identity?.IsAuthenticated != true) return null;
return user.FindFirstValue(ClaimTypes.NameIdentifier)
?? user.FindFirstValue("sub")
?? user.FindFirstValue("uid");
}
```
This is a memory-noted gotcha — `ICurrentUser` in a hub method returns the wrong identity (or null) once the connection has been alive for any length of time. Always go through `Context.User`.
## Integration testing SignalR
`TestServer` (which the kit uses for integration tests) has **no WebSocket transport**. Tests must force long-polling on the connection:
```csharp
var connection = new HubConnectionBuilder()
.WithUrl($"http://localhost/api/v1/realtime/hub?access_token={Uri.EscapeDataString(accessToken)}", options =>
{
options.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler();
// TestServer has no WebSocket transport — force long-polling.
options.WebSocketFactory = (_, _) => throw new NotSupportedException();
options.Transports = HttpTransportType.LongPolling;
options.Headers["tenant"] = TestConstants.RootTenantId;
})
.Build();
```
The Chat integration tests (`src/Tests/Integration.Tests/Tests/Chat/` — `RealtimeEventsTests`, `TypingIndicatorTests`, `PresenceTests`) all carry this `ConnectAsync` helper. Use it as the template for any new realtime integration test.
## Configuration
```jsonc
{
"CachingOptions": {
"Redis": "localhost:6379" // enables the SignalR backplane
},
"CorsOptions": {
"AllowAll": false,
"AllowedOrigins": ["https://app.example.com"]
}
}
```
Two important config notes:
- **CORS for credentialed requests.** `AllowAnyOrigin()` silently breaks SignalR while REST keeps working — the CORS spec forbids `*` with credentialed requests, and SignalR's negotiate always runs credentialed. The kit's `AddHeroCors` handles this: `AllowAll: true` uses `SetIsOriginAllowed(_ => true) + AllowCredentials()` (echoes the request origin instead of `*`); `AllowAll: false` uses an explicit origin allow-list with `AllowCredentials()`. Use the allow-list in prod.
- **Sticky sessions / connection affinity.** Long-polling fallback needs sticky sessions on the load balancer; WebSockets don't. Configure both layers to be safe.
## Related
- [Chat module](/docs/modules/chat/) — the canonical consumer of every realtime feature listed here.
- [Notifications module](/docs/modules/notifications/) — `NotificationCreated` push.
- [Server-Sent Events](/docs/cross-cutting-concerns/server-sent-events/) — one-way streaming when SignalR is overkill.
- [Caching](/docs/cross-cutting-concerns/caching/) — the Valkey backplane is shared with the cache.
---
# Server-Sent Events
> Built-in SSE endpoints — token exchange + authenticated stream — with a connection manager for one-way pushes when SignalR is overkill.
Source: https://fullstackhero.net/docs/cross-cutting-concerns/server-sent-events/
Server-Sent Events (SSE) are a small HTTP-streaming primitive: one connection, one direction, plain-text frames. The kit ships a complete SSE transport — a token-exchange endpoint, an authenticated stream endpoint, and a connection manager modules push events through — for the cases where SignalR is overkill: live feeds, usage gauges, status streams.
**Use SSE** when the server pushes to the client and the client doesn't push back. Smaller surface, no client library required, works through most proxies, lower memory footprint.
**Use SignalR** when you need bidirectional messaging (typing indicators, presence, chat), automatic reconnection logic, or you already have it wired (the Chat + Notifications modules already use SignalR — adding SSE alongside is fine).
## Opt in
```csharp
builder.AddHeroPlatform(o => o.EnableSse = true); // SseConnectionManager + token service
app.UseHeroPlatform(p => p.MapSseEndpoints = true); // maps the two endpoints below
```
The shipped host enables both.
## The two endpoints
| Endpoint | Auth | What it does |
|---|---|---|
| `POST /api/v1/sse/token` | JWT (standard `Authorization` header) | Issues a short-lived opaque stream token: `{ "token": "" }` |
| `GET /api/v1/sse/stream?token=` | The token from above | Opens the event stream |
Why the two-step dance: browsers' `EventSource` (and plain `fetch` streaming) can't attach an `Authorization` header the way XHR can, and putting a long-lived JWT in a URL leaks it into logs and proxies. So the client exchanges its JWT for a **single-use token that expires in 30 seconds**, then opens the stream with it. The token is stored in `IDistributedCache` (key `sse:tok:{guid:N}`), deleted on first consume, and carries only the user id + tenant id. An invalid or reused token gets a 401.
The token is checked **only at the stream handshake** — once the stream is open, its lifetime depends on the network/server, not the token. On reconnect the client simply requests a fresh token.
## What the stream sends
On connect, the endpoint sets `Content-Type: text/event-stream`, `Cache-Control: no-cache`, and `X-Accel-Buffering: no` (disables nginx buffering), then **immediately flushes a `:connected` comment frame**. That eager flush matters: Kestrel buffers response headers until the first body write, and without it the client's `fetch()` promise would sit pending until the first heartbeat — up to 15 seconds of "connecting…" on every reconnect.
After that the loop interleaves:
- **Events** — written in standard SSE framing (`id:` when set, `event:`, `data:` per line, blank line), flushed per event.
- **Heartbeats** — a `:heartbeat` comment every 15 seconds (fixed interval) so intermediaries (corporate proxies, load balancers) don't kill idle-looking connections.
Two headers you might expect are deliberately absent: `Connection: keep-alive` is a hop-by-hop header that's forbidden on HTTP/2+, so Kestrel would strip it and warn on every connect.
## Pushing events from the backend
Resolve the singleton `SseConnectionManager` and write `SseEvent` records:
```csharp
public sealed record SseEvent(string EventType, string Data, string? Id = null);
// targeted: every open tab/device of one user
connectionManager.TrySend(userId, new SseEvent("usage-updated", json));
// tenant-wide
connectionManager.Broadcast(tenantId, new SseEvent("maintenance-window", json));
// everyone (cross-tenant — use sparingly)
connectionManager.BroadcastAll(new SseEvent("platform-notice", json));
```
Each connection is backed by a bounded channel (100 events, `DropOldest`) — a slow client silently loses its oldest undelivered events instead of exerting backpressure on the producer. Per-instance only: the manager holds in-process connections, so in a multi-instance deployment an event raised on instance A doesn't reach a client connected to instance B. For cross-instance fan-out, use SignalR (which has the Valkey backplane) or publish through your own pub/sub before calling the manager.
## Client side
The kit's dashboard (`clients/dashboard/src/sse/`) is the reference client. It uses `fetch` + a streaming reader rather than `EventSource`, so it can send the `tenant` header and drive its own reconnect/backoff loop:
```ts
const { token } = await apiFetch<{ token: string }>("/api/v1/sse/token", { method: "POST" });
const response = await fetch(`${apiBase}/api/v1/sse/stream?token=${encodeURIComponent(token)}`, {
headers: { Accept: "text/event-stream", tenant },
signal: controller.signal,
});
const reader = response.body.getReader();
for await (const ev of parseSseStream(reader)) {
// ... handle ev.event / ev.data
}
```
When the stream drops, the loop requests a fresh token and reconnects with exponential backoff. Native `EventSource` also works if you don't need custom headers — point it at the stream URL with the token query parameter.
## What SSE doesn't do
- **No bidirectional messaging.** Server → client only. If the client needs to send anything back, use a separate HTTP request or switch to SignalR.
- **No native delivery guarantees.** Combined with the `DropOldest` channel, a slow or briefly-disconnected client can miss events. If you need catch-up, set `SseEvent.Id` and have the client send a "last seen" cursor on reconnect — that replay protocol is yours to build.
- **No cross-instance fan-out** — see above.
## When to reach for SignalR instead
- **Chat-like patterns** — bidirectional, presence, typing indicators, multi-channel.
- **Need client-driven actions** — start / stop / pause / resume from the client.
- **Multi-instance fan-out** — SignalR's Valkey backplane handles it; the SSE manager doesn't.
- **You already have SignalR enabled** — each extra mechanism is a maintenance surface. Pick one per feature.
## Related
- [Realtime (SignalR)](/docs/cross-cutting-concerns/realtime/) — bidirectional mode.
- [Chat module](/docs/modules/chat/) — SignalR usage example.
- [Web building block](/docs/building-blocks/web/) — `EnableSse` toggle, `SseConnectionManager`, `SseTokenService`.
---
# Overview
> Docker Compose, Dokploy, Terraform, and managed-service swaps for production.
Source: https://fullstackhero.net/docs/deployment/
Ship the kit to a single host or to managed cloud — recipes for each common path.
---
# Local Orchestration with .NET Aspire
> What spins up when you run the AppHost, and the decisions behind the local topology — Postgres, Valkey, MinIO, the migrator, the API, and both React apps.
Source: https://fullstackhero.net/docs/deployment/aspire/
One command brings up the entire stack — databases, cache, object storage, the
API, and both React apps — wired together with service discovery, health gating,
and a single dashboard:
```bash
dotnet run --project src/Host/FSH.Starter.AppHost
```
The composition lives in `src/Host/FSH.Starter.AppHost/AppHost.cs`. This page
documents what it starts and **why** it's wired the way it is.
## What spins up by default
| Resource | Aspire name | What it is | Lifetime |
|---|---|---|---|
| PostgreSQL | `postgres` | Primary database server; hosts the `fsh-db` database | Persistent (data volume) |
| pgAdmin | *(sidecar)* | Web UI on **:5050**, auto-discovers every database on the server | Persistent |
| Valkey | `redis` | Distributed cache (HybridCache L2) + SignalR backplane + Data Protection key ring (**Valkey** — a Redis-compatible, BSD-licensed Redis fork; resource name stays `redis`) | Persistent (data volume) |
| RedisInsight | `redis-insight` | Key browser on **:5540**, pre-connected to the Valkey instance for inspecting cache keys in dev | Persistent |
| MinIO | `minio` | S3-compatible object storage, **:9000** (API) / **:9001** (console) | Persistent (data volume) |
| MinIO init | `minio-init` | One-shot: creates the `fsh-uploads` bucket + download policy, then exits | Run-once |
| DB migrator | `fsh-starter-db-migrator` | One-shot: applies migrations across the tenant catalog + every tenant's module DBs (`apply --seed`, so the root admin exists), then exits | Run-once |
| Demo seeder | `fsh-starter-demo-seeder` | One-shot, **dev-only**: runs `seed-demo` after the migrator to provision the `acme`/`globex` demo tenants + users, then exits | Run-once |
| API | `fsh-starter-api` | The ASP.NET Core API (`net10.0`) | Long-running |
| Admin app | `fsh-starter-admin` | Operator React + Vite SPA on **:5173** | Long-running |
| Dashboard app | `fsh-starter-dashboard` | Tenant React + Vite SPA on **:5174** (with SSE live feed) | Long-running |
The `fsh-starter-*` resource names — and the Docker volume names — are derived
from the AppHost's assembly name. A CLI-scaffolded app (say `Acme.Store`) gets
`acme-store-api`, `acme-store-admin`, and so on, so two FSH-based apps on one
machine never collide on container or volume names. The third-party infra
(`postgres`, `redis`, `minio`) and the `fsh-db` database keep stable names.
The Aspire dashboard opens automatically and shows every resource's state,
logs, traces, and endpoints. The API's Scalar UI is at `/scalar`.
The API waits for Postgres and Valkey to be healthy **and** for the one-shot
jobs (`minio-init`, `fsh-starter-db-migrator`, `fsh-starter-demo-seeder`) to finish before it starts. So the API
never boots against an unmigrated database or a missing bucket — no retry loops,
no first-request 500s.
## Design decisions
### Persistent infra, run-once jobs
Postgres, Valkey, and MinIO use `ContainerLifetime.Persistent` with named data
volumes. Your data, pgAdmin layout, and uploaded files survive `dotnet run`
restarts, so the inner loop stays fast. The migrator and MinIO bootstrap are
**run-once** — they do their job and exit rather than linger as "unhealthy."
### MinIO is S3, so dev matches prod
Object storage uses the same `Storage__Provider = "s3"` code path locally as in
production — only `ServiceUrl` and `ForcePathStyle` differ. The Files module's
presigned-URL upload flow (browser → storage directly, bytes never proxied
through the API) is exercised end-to-end in dev. MinIO is configured to accept
browser PUTs from the admin (`:5173`) and dashboard (`:5174`) origins via
`MINIO_API_CORS_ALLOW_ORIGIN`.
### The migrator is the production deploy step too
The `fsh-starter-db-migrator` resource runs the same `FSH.Starter.DbMigrator`
project you run as an explicit step in production (published as the
`fsh-db-migrator` image; `dotnet run --project ... -- apply`). The database
is **never** migrated at API startup — locally or in the cloud. One mechanism,
two contexts.
### Valkey for caching and realtime, with a RedisInsight browser
The cache engine is **Valkey** (a Redis-compatible, BSD-licensed Redis fork),
pulled as `valkey/valkey:9.1.0`. It speaks the Redis protocol (RESP), so the
.NET client (`StackExchange.Redis`) and every `CachingOptions:Redis` config key
are unchanged — and the resource keeps the name `redis` in code. It backs the
HybridCache L2, the SignalR backplane, and the Data Protection key ring
(Hangfire uses PostgreSQL storage). A **RedisInsight** container rides along,
pre-registered against the Valkey instance via `RI_REDIS_*` environment
variables, so you can inspect cache keys and TTLs in dev with no manual setup.
### Valkey is a plain container, on plain TCP
Aspire 13.4's `AddRedis()` forces TLS-by-default in run mode (which breaks
StackExchange.Redis negotiation through the Aspire proxy) and never materializes
the container. The AppHost therefore adds Valkey via `AddContainer(...)` with a
plain-TCP endpoint, and the API runs with `CachingOptions__EnableSsl = false`.
Keep it that way.
### The Postgres image is unpinned
`AddPostgres("postgres")` doesn't pin an image tag — it tracks whatever the
Aspire hosting package defaults to (PostgreSQL 18 as of Aspire 13.4). When a
package update jumps a Postgres **major** version, the persistent
`*-postgres-data` volume written by the older major can't be read and the
container exits. Recovery for local dev data: remove the volume and relaunch —
the migrator + seeders rebuild everything.
### Frontends target the API's HTTPS endpoint
Both React apps get `VITE_API_BASE_URL` pointing at the API's **https** endpoint,
not http. The API uses `UseHttpsRedirection()`, so a call to the http endpoint
307-redirects to https — and browsers strip the `Authorization` header on
cross-origin redirects (a different scheme/port is cross-origin per the Fetch
spec). Hitting https directly preserves the bearer token on every request. The
Vite apps run un-proxied on fixed ports (5173 / 5174) so HMR works and the
origins line up with the MinIO CORS allow-list.
### The React apps are optional
The `//#if (frontend)` directives around the two `AddJavaScriptApp(...)` calls
are `dotnet new` template conditionals. Scaffold with the backend-only option
and the AppHost omits both React apps entirely.
## Ports
| Service | Port |
|---|---|
| API | 7030 (https) · 5030 (http) |
| Admin app | 5173 |
| Dashboard app | 5174 |
| Postgres | 5432 |
| pgAdmin | 5050 |
| Valkey | 6379 (container) |
| RedisInsight | 5540 |
| MinIO | 9000 (API) · 9001 (console) |
## From local to cloud
The local topology maps almost one-to-one onto AWS: Postgres → RDS, Valkey →
ElastiCache, MinIO → S3, the API container → ECS Fargate, and the two React apps
→ S3 + CloudFront. See [Deploy to AWS with Terraform](/docs/deployment/aws-terraform/).
---
# Deploy to AWS with Terraform
> End-to-end AWS deployment — prerequisites, bootstrapping the state backend, configuring an environment, and the one-command deploy that ships the API and both React apps.
Source: https://fullstackhero.net/docs/deployment/aws-terraform/
The kit ships production-grade Terraform that stands up the whole stack on AWS
and publishes the API and both React apps with **one command**. This guide takes
you from nothing installed to a running deployment.
The Terraform lives under `deploy/terraform/`.
## What gets provisioned
```
┌─────────────── CloudFront (OAC) ──────────────┐
Browser ──▶ admin SPA ─┤ private S3 │
──▶ dashboard ─┘ private S3 │
│
Browser/API ──▶ CloudFront ──▶ ALB (+ WAF) ──▶ ECS Fargate: fsh-api ──▶ RDS PostgreSQL
(HTTPS, opt) │ └─▶ ElastiCache Redis
├──────────────▶ S3 (file uploads, + CloudFront)
│
one-shot ECS task: fsh-db-migrator ─────────┴──────────────▶ RDS (migrate + seed)
```
- **Network** — VPC across your AZs, public + private subnets, NAT gateway(s), and VPC endpoints (S3, ECR, Logs, Secrets Manager) to keep traffic off the public internet.
- **API** — ECS Fargate service behind an Application Load Balancer, fronted by **AWS WAF** (rate limiting + managed rule sets). Autoscaling, deployment circuit breaker, container in private subnets. Optionally fronted by **CloudFront** (`enable_api_cloudfront`) so the HTTPS SPAs can call the API over HTTPS with no custom domain (no mixed-content).
- **Migrations** — the schema is applied by a **one-shot `fsh-db-migrator` ECS task** the deploy runs after `apply` (never at API startup). It migrates and — in dev — seeds the admin user and default tenant; demo tenants are opt-in.
- **Data** — RDS PostgreSQL (optional Multi-AZ, AWS-managed master password in Secrets Manager) and ElastiCache Redis/Valkey (encryption in transit + at rest).
- **Files** — an S3 bucket for application uploads, optionally fronted by CloudFront.
- **Frontends** — the admin and dashboard React SPAs each on a **private S3 bucket + CloudFront** (Origin Access Control), with SPA routing, default-on security headers (HSTS, nosniff, frame, referrer), and a Terraform-managed `config.json`.
- **Observability** — CloudWatch alarms (opt-in) for ECS, RDS, Redis, and the ALB.
Out of the box the SPAs and API are reachable on their AWS-assigned hostnames —
CloudFront `*.cloudfront.net` for the SPAs (and the API too, when
`enable_api_cloudfront` is set), or the ALB DNS name otherwise. Custom domains
are optional — the per-app `*_cloudfront_aliases` / `*_certificate_arn`
variables are present but commented out in the example tfvars. Bring your own
ACM cert (in `us-east-1` for CloudFront) and DNS when you're ready.
## Prerequisites
### AWS
- An **AWS account** and an IAM principal (user or SSO role) with permissions to create the resources above. The simplest start is an admin-level role; scope it down later.
- **AWS CLI v2**, configured: `aws configure` (or `aws configure sso`). Verify with `aws sts get-caller-identity`.
- A **container registry** the ECS tasks can pull from. Two images are used — the API (`ghcr.io/fullstackhero/fsh-api`) and the migrator (`ghcr.io/fullstackhero/fsh-db-migrator`), built together from the same commit and sharing one immutable tag. The defaults are the public GHCR images; point `container_registry` at your own (ECR/GHCR) if you publish your own builds.
### Tooling
| Tool | Version | Needed for |
|---|---|---|
| Terraform | **>= 1.15.4** | everything |
| AWS CLI | v2 | auth, S3 sync, CloudFront invalidation, the migrator ECS task |
| jq | any | reading Terraform outputs in **`deploy.sh`** (the PowerShell `deploy.ps1` uses `ConvertFrom-Json` — no jq) |
| Node | 20+ | building the React apps |
| .NET SDK | 10 | only with `--build-api` (building/pushing the API + migrator images) |
The configuration pins `required_version >= 1.15.4` and the AWS provider to
`~> 6.46`. An older CLI will be rejected at `terraform init`. Upgrade with e.g.
`choco upgrade terraform` (Windows), `brew upgrade terraform` (macOS), or your
package manager. A committed `.terraform.lock.hcl` keeps provider versions
identical across machines and CI.
Verify:
```bash
terraform version # >= 1.15.4
aws sts get-caller-identity
jq --version
node --version # >= 20
```
## Step 1 — Bootstrap the state backend (one-time)
Terraform stores its state in an S3 bucket with native locking (no DynamoDB
needed). Create it once per account:
```bash
cd deploy/terraform/bootstrap
terraform init
terraform apply -var bucket_name=my-org-fsh-tfstate
```
This creates a versioned, encrypted, TLS-only S3 bucket with `prevent_destroy`.
Put its name into each environment's backend config at
`deploy/terraform/apps/starter/envs///backend.hcl`:
```hcl title="envs/dev/us-east-1/backend.hcl"
bucket = "my-org-fsh-tfstate"
key = "dev/us-east-1/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
```
## Step 2 — Configure your environment
Each environment is a `*.tfvars` file under
`deploy/terraform/apps/starter/envs///`. Edit the values for your
account — at minimum the **globally unique** S3 bucket names:
```hcl title="envs/dev/us-east-1/terraform.tfvars"
environment = "dev"
region = "us-east-1"
# S3 buckets — must be globally unique across all of AWS
app_s3_bucket_name = "my-org-dev-fsh-app"
dashboard_s3_bucket_name = "my-org-dev-fsh-dashboard"
admin_s3_bucket_name = "my-org-dev-fsh-admin"
# HTTPS for the API with no custom domain: front the ALB with CloudFront so the
# HTTPS SPAs can call it without mixed-content errors.
enable_api_cloudfront = true
db_name = "fshdb"
db_username = "fshadmin"
db_manage_master_user_password = true # AWS manages the password in Secrets Manager
# Container images — the API and migrator share one immutable tag (never "latest").
# CI publishes "dev-"; `deploy.sh --build-api` publishes a 12-char short SHA.
container_registry = "ghcr.io/fullstackhero"
api_image_name = "fsh-api"
migrator_image_name = "fsh-db-migrator"
container_image_tag = "dev-"
# What the one-shot migrator task runs. Dev migrates AND seeds (admin + default
# tenant); seeding is idempotent. Demo tenants (acme/globex) are opt-in via --seed-demo.
migrator_command = ["apply", "--seed"]
```
`dev`, `staging`, and `prod` ship as ready-to-edit examples (prod enables
Multi-AZ, deletion protection, autoscaling, and CloudWatch alarms). `dev` ships
for **two** regions — `us-east-1` and `ap-south-1` — as a multi-region template.
## Step 3 — Deploy (one command)
From `deploy/terraform/apps/starter/`. The **region is required** — pass it as
the second argument (the scripts never assume one; they prompt when it is
omitted interactively and fail in CI):
```bash title="bash / CI / macOS / Linux"
./deploy.sh dev us-east-1
```
```powershell title="Windows PowerShell"
./deploy.ps1 -Environment dev -Region us-east-1
```
That single command runs, in order:
1. `terraform init` + `apply` for the env/region (all the infra above).
2. *(optional, `--build-api`)* builds and pushes **both** the API and migrator container images at the current git SHA and deploys that tag.
3. runs the **`fsh-db-migrator` one-shot ECS task** (`apply` / `apply --seed`) and waits for it to exit `0` — the schema is applied here, not at API startup.
4. `npm run build` for each React app, `aws s3 sync` to its bucket (keeping the Terraform-managed `config.json`), and a CloudFront invalidation.
First deploy, building the images yourself, with no prompts:
```bash
./deploy.sh prod us-east-1 --build-api --auto-approve
```
### Flags
| `deploy.sh` | `deploy.ps1` | What |
|---|---|---|
| `--build-api` | `-BuildApi` | Build & push the API **and** migrator images at the current git SHA and deploy that tag (needs the .NET SDK + a registry login). |
| `--image-tag TAG` | `-ImageTag TAG` | Deploy a specific, already-published tag. |
| `--registry REG` | `-Registry REG` | Container registry (default `ghcr.io/fullstackhero`); only used with build. |
| `--skip-migrate` | `-SkipMigrate` | Skip the migrator ECS task after apply. |
| `--seed-demo` | `-SeedDemo` | After migrating, also run the migrator's `seed-demo` verb (acme/globex demo tenants). |
| `--skip-frontend` | `-SkipFrontend` | Skip building/publishing the SPAs (infra + migrate only). |
| `--auto-approve` | `-AutoApprove` | Don't prompt before `terraform apply`. |
Each SPA's `config.json` (its `apiBase`, default tenant, etc.) is written by
Terraform so the front ends always point at the right API. The deploy step syncs
your build with `--exclude config.json` so it's never clobbered — one build
artifact promotes across every environment unchanged.
### Database migrations & seeding
The deploy never migrates at API startup. After `terraform apply` it runs the
**`fsh-db-migrator` image as a one-shot Fargate task** in the private subnets,
with the verb from `migrator_command` (dev: `["apply", "--seed"]`), and fails
the deploy if the task exits non-zero (pointing you at its CloudWatch log group).
Seeding is idempotent. The **acme/globex demo tenants** are opt-in — add
`--seed-demo` / `-SeedDemo` to also run the migrator's `seed-demo` verb. Skip
the whole step with `--skip-migrate` / `-SkipMigrate`.
### Prefer raw Terraform?
```bash
cd deploy/terraform/apps/starter/app_stack
terraform init -backend-config=../envs/dev/us-east-1/backend.hcl
terraform apply -var-file=../envs/dev/us-east-1/terraform.tfvars
```
Then run the migrator task and publish each SPA by hand (see the app stack
`README.md`) — or just use `deploy.sh` / `deploy.ps1`, which do all three.
## Step 4 — Find your endpoints
```bash
cd deploy/terraform/apps/starter/app_stack
terraform output api_url # HTTPS CloudFront URL when enable_api_cloudfront, else the ALB URL
terraform output dashboard_site # { bucket_name, cloudfront_domain_name, url, ... }
terraform output admin_site
```
Open the CloudFront `url` from each site output to reach the apps. The API's
CORS allow-list is wired automatically to include both SPA origins.
## Layout reference
```
deploy/terraform/
├── bootstrap/ # one-time S3 state backend
├── modules/ # reusable building blocks
│ ├── network/ alb/ waf/ ecs_cluster/ ecs_service/
│ ├── rds_postgres/ elasticache_redis/ s3_bucket/
│ ├── ecs_task/ # the one-shot DbMigrator task
│ ├── static_site/ # private S3 + CloudFront for an SPA
│ └── cloudwatch_alarms/
└── apps/starter/
├── app_stack/ # the root config — run Terraform here
├── envs/// # backend.hcl + terraform.tfvars per environment
├── deploy.sh / deploy.ps1 # one-command deploy (infra → migrate → publish SPAs)
└── destroy.ps1 # tear one env/region down
```
## Security defaults
- API tasks run in **private subnets** with no public IP; only the ALB is internet-facing, and it sits behind **WAF**.
- The ALB drops invalid headers and runs desync mitigation in `defensive` mode.
- RDS and Redis security groups are scoped to the VPC CIDR; Redis uses encryption in transit + at rest; the DB password is managed in **Secrets Manager** and injected via the ECS task execution role.
- SPA buckets are **private** — CloudFront reads them through Origin Access Control, and a managed response-headers policy adds HSTS and the usual hardening headers.
- The API container runs as a **non-root** user (`1654`) on the .NET `noble` runtime base image.
## Teardown
The `destroy.ps1` helper re-points the backend and destroys one env/region in
one step:
```powershell title="Windows PowerShell"
./destroy.ps1 -Environment dev -Region us-east-1
```
```bash title="raw Terraform (bash)"
cd deploy/terraform/apps/starter/app_stack
terraform init -reconfigure -backend-config=../envs/dev/us-east-1/backend.hcl
terraform destroy -var-file=../envs/dev/us-east-1/terraform.tfvars
```
`dev` sets `force_destroy` on buckets and skips the RDS/Redis final snapshot, so
it tears down cleanly and repeatably. `staging`/`prod` enable RDS deletion
protection and ALB deletion protection — you must disable those (and empty the
buckets) before a destroy will succeed. Tearing down the app stack does **not**
remove the GHCR images or the Terraform state bucket from Step 1 (which has
`prevent_destroy`).
## Next
- [Local Orchestration with Aspire](/docs/deployment/aspire/) — the dev-time mirror of this topology.
---
# CI/CD Pipeline
> The GitHub Actions setup — path-scoped backend and frontend pipelines, a pinned SDK, deduplicated test + coverage, CodeQL, the template smoke guard, and how container images and NuGet packages are published.
Source: https://fullstackhero.net/docs/deployment/ci-cd/
The kit ships **four** GitHub Actions workflows in `.github/workflows/`:
| Workflow | Purpose |
|---|---|
| **`backend.yml`** | Builds, tests, and publishes the .NET backend (API + DbMigrator containers, CLI + template NuGet packages). Runs only when backend code changes. |
| **`frontend.yml`** | Lints, builds, and E2E-tests the two React apps. Runs only when `clients/**` changes. |
| **`codeql.yml`** | CodeQL security analysis on backend PRs + a weekly scan. |
| **`template-smoke.yml`** | Guards the distribution path: scaffolding from the `dotnet new` template must always build. |
Two design choices shape everything below:
- **Path-scoped pipelines.** A change under `clients/**` never builds or tests the API, and a change under `src/**` never builds the React apps. You only pay for — and wait on — the checks relevant to what you touched.
- **A pinned SDK.** A root `global.json` pins the .NET 10 **GA** SDK (`rollForward: latestFeature`), and every workflow resolves it with `setup-dotnet`'s `global-json-file`. CI is deterministic and never silently pulls a preview SDK. (`global.json` is excluded from the `dotnet new` template, so scaffolded projects aren't pinned to the kit's SDK band.)
The repo uses a single long-lived branch, **`main`** (the default). Workflows trigger on pushes and PRs to `main` plus `v*` tags — there is no `develop` branch. See [Contributing](/docs/contributing/) for the branching model.
## backend.yml — build, test, publish
Triggers on pushes to `main`, `v*` tags, and PRs to `main`, plus manual `workflow_dispatch` (with an optional `version` input). PR runs cancel in-progress duplicates.
| Job | Depends on | Runs when | What it does |
|---|---|---|---|
| **Detect changes** | — | always | A `dorny/paths-filter` step decides whether backend code changed. On any push/tag/dispatch it's always `true`; on a PR it's `true` only when `src/**`, `global.json`, `coverage.runsettings`, or the workflow itself changed. Every heavy job gates on this. |
| **Unit Tests** | Detect changes | backend changed | `dotnet build -c Release -warnaserror` (the build gate), an audit that **fails on directly-referenced vulnerable packages** (transitive advisories are reported, not blocking), then the 12 unit/architecture test projects **with coverage collection**. Uploads the coverage as an artifact. |
| **Integration Tests** | Detect changes | backend changed | `Integration.Tests` + `Integration.Middleware.Tests` via `WebApplicationFactory` + Testcontainers (Docker on the runner), **with coverage collection**. Uploads its coverage as an artifact. |
| **DbMigrator Container Smoke** | Detect changes | backend changed | Publishes the `fsh-db-migrator` image (Dockerfile-less SDK publish) and runs `apply --catalog-only` against an ephemeral Postgres 17 — asserts it finishes successfully. Catches container-publish and DI-graph regressions. |
| **Coverage Gate** | Unit + Integration | backend changed | **Merges** the coverage artifacts from the two test jobs with ReportGenerator and **fails if line coverage drops below 80%**. It does *not* re-run the tests — a ratchet; raise `MIN_LINE` as coverage grows. |
| **Publish Dev Containers** | Unit + Integration | push to `main` | Publishes the API **and** DbMigrator images to GHCR tagged `dev-` and `dev-latest`. |
| **Publish Release** | Unit + Integration | `v*` tag, or `workflow_dispatch` on `main` | Packs the `fsh` CLI + the `dotnet new` template, pushes both to NuGet.org, and publishes the API + DbMigrator images to GHCR tagged `` and `latest`. |
| **Backend CI** | the test, smoke, and coverage jobs | always | The single required status check. Always runs (even when the heavy jobs are skipped on a client-only PR) and fails only if a job it depends on actually **failed or was cancelled** — skipped is fine. Publish jobs are deliberately not gated on. |
Each test job collects coverage as it runs, and the **Coverage Gate** downloads both artifacts and merges them. Earlier the gate re-ran the whole solution on top of the dedicated test jobs, so every push tested the code twice. Merging keeps the same 80% floor while running each test exactly once.
The migrator smoke job publishes the **same** `fsh-db-migrator` image you run in production and applies migrations against a throwaway Postgres. That's why the migrator is the recommended production deploy step — see [Database Migrations](/docs/deployment/database-migrations/).
### Required status checks and the gate jobs
Because the pipelines are path-scoped, a client-only PR never starts the backend jobs — so a branch-protection rule that required, say, "Unit Tests" directly would block forever waiting on a check that never reports. To avoid that, each workflow ends with a small **gate job** — `Backend CI` and `Frontend CI` — that *always* runs and turns green when its side is skipped.
**Require only `Backend CI` and `Frontend CI`** in your branch ruleset on `main` — not the individual jobs. They resolve correctly for backend-only, frontend-only, and cross-cutting PRs alike.
### Releasing
A release publishes exactly two **NuGet packages** — the `fsh` CLI tool and the `dotnet new` template — plus the **API and DbMigrator container images**. The distribution model is source-ownership: consumers get the full source via the template, not per-module/BuildingBlocks packages. Two ways to trigger it:
```bash
# Tag-driven (recommended): the tag name is the version
git tag v10.0.0 && git push origin v10.0.0
```
…or run the **Backend CI** workflow manually from the Actions tab on `main` with a `version` input (e.g. `10.0.1`).
`GITHUB_TOKEN` (auto-provided) publishes container images to GHCR. Releases also
need a `NUGET_API_KEY` repository secret to push packages to NuGet.org.
## frontend.yml — lint, build, E2E
Triggers on pushes and PRs to `main` (and manual dispatch). A `Detect changes` job gates the work on `clients/**`. Both apps run as a matrix (`admin`, `dashboard`) on **Node 22** with npm caching.
| Job | Runs when | What it does |
|---|---|---|
| **Detect changes** | always | Sets `frontend = true` on any push/dispatch, or on a PR when `clients/**` (or the workflow) changed. |
| **Lint & Build** *(matrix)* | frontend changed | `npm ci`, `npm run lint` (ESLint), then `npm run build` — which is `tsc -b && vite build`, so it's the typecheck **and** the bundle gate. |
| **E2E** *(matrix)* | frontend changed | `npm ci`, installs the Playwright Chromium browser, then `npm run test:e2e`. The Playwright config boots its own Vite dev server and mocks all API calls via `page.route()`, so **no backend is required**. Uploads the Playwright HTML report on failure. |
| **Frontend CI** | always | The single required status check for the frontend side (same always-green-when-skipped behaviour as `Backend CI`). |
## codeql.yml — security analysis
Runs CodeQL (`csharp`) on pull requests to `main` scoped to `src/**`, plus a scheduled weekly scan. Uses the pinned SDK via `global.json`.
## template-smoke.yml — distribution guard
Runs on changes to `.template.config/**`, `templates/**`, `clients/**`, or `src/**`. It proves a freshly scaffolded project actually builds:
- **Scaffold (Aspire + React)** — packs the template, installs the `.nupkg`, runs `dotnet new fsh ... --aspire true --frontend true`, builds the backend (`-warnaserror`), builds **both** React apps (`npm ci && npm run build` on Node 22), and runs the Architecture tests on the scaffolded output.
- **Scaffold (minimal)** — scaffolds backend-only (`--aspire false --frontend false`) and builds it, guarding the `//#if (frontend)` / `(!aspire)` conditional gating in `AppHost.cs` and the solution.
## How CI feeds deployment
The image CI publishes is what you deploy. Reference it by its immutable tag in your environment's `terraform.tfvars`:
```hcl
container_image_tag = "v10.0.0" # or a dev- tag from a main build
```
Then deploy — see [Deploy to AWS with Terraform](/docs/deployment/aws-terraform/). For a fully automated path, run the one-command deploy from your own deploy workflow after the image is published.
## Local parity
Every backend CI check has a local equivalent, so you can reproduce a red build before pushing:
```bash
dotnet build src/FSH.Starter.slnx -c Release -warnaserror # Unit Tests build gate
dotnet test src/FSH.Starter.slnx -c Release # Unit + Integration (needs Docker)
```
For the frontend, in each of `clients/admin` and `clients/dashboard`:
```bash
npm ci && npm run lint && npm run build # Lint & Build
npm run test:e2e # E2E (route-mocked; boots Vite itself)
```
---
# Database Migrations (DbMigrator)
> The FSH DbMigrator — the one-shot tool that applies EF Core migrations across the tenant catalog and every tenant's per-module databases. The database is never migrated at API startup.
Source: https://fullstackhero.net/docs/deployment/database-migrations/
`FSH.Starter.DbMigrator` is a standalone console app that applies EF Core
migrations. It is the **single, authoritative way** the database schema is
created and evolved — locally and in production.
The API does **not** run migrations when it boots. Schema changes are always an
explicit, observable step you run on purpose. This avoids racing instances all
trying to migrate at once, gives you a clear go/no-go before traffic shifts, and
keeps a multi-tenant migration (which touches many databases) out of the request
path.
## What it does
Each run migrates, in order:
1. **The tenant catalog** — the control-plane database that lists tenants.
2. **Every tenant's per-module databases** — one pass per tenant, applying each module's migrations.
Migrations themselves live in `src/Host/FSH.Starter.Migrations.PostgreSQL`,
organized per module by folder.
## Commands
```bash
dotnet run --project src/Host/FSH.Starter.DbMigrator -- [verb] [options]
```
| Verb | What it does |
|---|---|
| `apply` *(default)* | Apply pending migrations. Add `--seed` to also run the per-tenant seed. |
| `seed` | Run only the seed step per tenant (no migration). |
| `seed-demo` | Provision demo tenants (`acme`, `globex`) with users, catalog, tickets, and chat. **Dev-only** — refuses to run unless `DOTNET_ENVIRONMENT=Development` (the migrator is a generic-host console app, so it reads `DOTNET_ENVIRONMENT`, **not** `ASPNETCORE_ENVIRONMENT`). Under Aspire this runs automatically — see [Local development](#local-development). |
| `list-pending` | Print pending migrations without applying anything. |
| Option | Effect |
|---|---|
| `--tenant ` | Restrict to a single tenant id (default: all tenants). |
| `--catalog-only` | Migrate only the tenant catalog; skip the per-tenant pass. |
| `--seed` | After `apply`, also call `SeedTenantAsync` per tenant. |
| `-h`, `--help` | Print help. |
**Exit codes:** `0` success · `1` failure (the exception is logged). The non-zero
code is what your CI/CD pipeline should gate the deploy on.
```bash
# preview, apply, apply+seed, scope to one tenant, catalog only
dotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending
dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply
dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply --seed
dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply --tenant acme
dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply --catalog-only
```
## Local development
When you run the stack via Aspire, the migrator runs **for you**: the
`fsh-starter-db-migrator` resource executes `apply --seed` on each AppHost launch (so the
root admin `admin@root.com` is seeded), then a dev-only `fsh-starter-demo-seeder` resource
runs `seed-demo` to provision the `acme`/`globex` demo tenants and their users. The
API is gated on both (`WaitForCompletion`), so it never starts against an
unmigrated database and the demo logins work the moment the dashboard loads. See
[Local Orchestration with Aspire](/docs/deployment/aspire/).
You can also run it directly any time:
```bash
dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply --seed
```
To populate demo tenants for a local demo or test drive (Aspire already does this
for you on launch; run it manually only when you're **not** using Aspire — set
`DOTNET_ENVIRONMENT=Development` so the dev-only verb proceeds):
```bash
DOTNET_ENVIRONMENT=Development dotnet run --project src/Host/FSH.Starter.DbMigrator -- seed-demo
```
## Production
In production the migrator is the **explicit deploy step that runs before the
new API serves traffic**. It's published as its own container image
(`fsh-db-migrator`) so you can run it as a one-off task right next to the API.
RDS lives in private subnets, so the migrator has to run somewhere that can
reach it — typically a one-off **ECS Fargate task** using the `fsh-db-migrator`
image in the same private subnets and security group context as the API. A
laptop or a CI runner outside the VPC can't connect to a private RDS instance.
A typical pipeline order:
1. Build & push the new `fsh-api` and `fsh-db-migrator` images (same tag).
2. `terraform apply` to provision/update infra.
3. Run the migrator as a one-off ECS task: `aws ecs run-task` with the
`fsh-db-migrator` image, command `apply`, in the private subnets. Wait for it
to exit `0`.
4. Roll out the new API task definition.
The [one-command AWS deploy](/docs/deployment/aws-terraform/) provisions infra
and ships the API + SPAs, but it does **not** run the migrator (that needs
in-VPC access to RDS). Run the migrator task as the step between `terraform
apply` and shifting traffic — wire it into your CI/CD or run it as a one-off
ECS task.
## Adding a module or a migration
- **New migration** — generate it against `FSH.Starter.Migrations.PostgreSQL` (see the database rules / `create-migration` recipe), build, then `list-pending` to confirm and `apply`.
- **New module** — the migrator has its **own** module wiring. Registering a module touches `DbMigrator/Program.cs` as well as the API's `Program.cs`; miss it and the module's migrations silently never run. See [Architecture](/docs/architecture/) for the full "four places" checklist.
`list-pending` is free and side-effect-free. Run it in CI on every PR to catch
un-applied or accidentally-removed migrations before they reach an environment.
## Next
- [Local Orchestration with Aspire](/docs/deployment/aspire/) — how the migrator is wired into the dev loop.
- [Deploy to AWS with Terraform](/docs/deployment/aws-terraform/) — where the migrator step fits in a cloud rollout.
---
# Overview
> Two React + Vite + TypeScript apps ship with the kit — an admin console for operators and a tenant dashboard for end users.
Source: https://fullstackhero.net/docs/frontend/
Two React + Vite + TypeScript apps ship in `clients/` — both production-grade, both wired against the API's auth + multitenancy + realtime out of the box. Pick the right one for your audience: **admin** is for operators (back-office, support, ops); **dashboard** is for end users (the people who pay you).
Both apps follow the same conventions for auth, API integration, route guards, and realtime. They don't share a component library — the admin's audit log + impersonation pages don't belong in the user-facing dashboard, and the dashboard's chat / catalog pages don't belong in admin. Sharing patterns + diverging on features is the right axis.
## The two apps
## Quick facts
| | **clients/admin** | **clients/dashboard** |
|---|---|---|
| Audience | Operators, support, ops | End users (tenant members) |
| Default port | 5173 | 5174 |
| Pages | Users, Roles, Tenants, Impersonation, Audits, Webhooks, Billing (plans + invoices), Health, Notifications, Settings | Overview, Activity, Catalog, Chat, Files, Tickets, Invoices, Subscription, Identity (users / roles / groups), System (sessions, trash, audits, health), Settings |
| Realtime | SignalR notifications | SignalR (chat + notifications + presence) + an SSE activity feed |
| Auth | Same JWT bearer + refresh flow as the API | Same |
| Theming | Kit palette + per-operator light/dark | Per-user appearance (mode, accent, font, density); per-tenant `TenantTheme` lives in the Multitenancy module |
| Inactivity sign-out | 10 min idle + 60 s warning (config.json) | 20 min idle + 60 s warning (config.json) |
Both run via `npm run dev` (Vite dev server with HMR) and build to static assets via `npm run build`. The kit's Aspire AppHost orchestrates both alongside the API for one-command local dev. Both read runtime configuration from `/config.json` at boot — no `VITE_*` build-time env baked into the bundle — so a single build artifact promotes across environments.
## Related
- [Admin console](/docs/frontend/admin/) — operator-facing app.
- [Tenant dashboard](/docs/frontend/dashboard/) — end-user app.
- [Frontend architecture](/docs/frontend/architecture/) — shared patterns.
- [Theming](/docs/frontend/theming/) — per-tenant brand customisation.
- [Local development](/docs/frontend/development/) — running, building, deploying.
---
# Admin console
> The operator-facing React + Vite app at clients/admin — users, roles, tenants, impersonation, audits, billing, webhooks, health, notifications.
Source: https://fullstackhero.net/docs/frontend/admin/
`clients/admin` is the operator-facing app. It's where support engineers reset passwords, ops sees the audit trail, billing reviews tenant invoices, and SuperAdmins start impersonation sessions. It's not customer-facing — it has no per-tenant theming; the chrome stays in the kit's own palette (operators still get a light/dark toggle).
## Tech stack
| | Version | Used for |
|---|---|---|
| React | 19.x | UI framework |
| Vite | 7.x | Dev server + build |
| TypeScript | 5.x | Type safety |
| TanStack Query | 5.x | Server-state cache + mutations |
| React Router | 7.x | Routing + protected routes |
| react-hook-form + zod | 7.x / 3.x | Forms + schema validation (admin only — the dashboard doesn't use them) |
| @microsoft/signalr | 10.x | Realtime connection to the kit's hub |
| Tailwind CSS | 4.x | Styling (shadcn-style, with CVA) |
| Radix UI | 1.x–2.x | Headless primitives (Dialog, DropdownMenu, etc.) |
| Lucide | 0.4xx | Icon set |
| Playwright | 1.x | Route-mocked E2E tests (`npm run test:e2e`) |
All pinned in `clients/admin/package.json`.
## Page inventory
```
clients/admin/src/pages/
├── audits/ Audit trail query UI (filter, detail side sheet)
├── auth/ Forgot / reset password, email confirmation
├── billing/ Plans + invoices (list + detail)
├── health/ System health probes
├── impersonation/ Grant list — active + historical, revoke
├── notifications/ Inbox, mark-read, real-time push
├── roles/ Role CRUD + permission editor
├── settings/ Profile, security (2FA), sessions, appearance
├── tenants/ Tenant CRUD + branding editor + provisioning status
├── users/ User search / detail / impersonate action
├── webhooks/ Subscription management + delivery log
├── dashboard.tsx Operator landing page
└── login.tsx Tenant + email + password sign-in
```
The route definitions live in `clients/admin/src/routes.tsx`; pages beyond the shell + landing page are lazy-loaded so each route becomes its own bundle chunk.
## Authentication
`clients/admin/src/auth/` owns the auth surface:
- `auth-context.tsx` — React context providing the signed-in user, `login`, `logout`, and `refreshPermissions`.
- `api.ts` — `issueToken()` wraps the login endpoint.
- `token-store.ts` — wraps localStorage for access + refresh tokens and the active tenant.
- `jwt.ts` — base64url decode + claim parsing (no library needed for read-only access).
- `protected-route.tsx` — route guard that requires authentication; redirects to `/login` if not.
- `route-guard.tsx` — permission-aware wrapper; renders `` if the user lacks the required permissions.
- `use-auth.ts` — hook for accessing the context from components.
- `inactivity.ts` + `use-inactivity-timeout.ts` — idle auto sign-out (see below).
Login flow:
```ts
const { login } = useAuth();
await login({ email, password, tenant });
// Calls POST /api/v1/identity/token/issue with the `tenant` header (and an
// X-FSH-App: admin header so the API can enforce the operator-app boundary),
// stores both tokens, then navigates to the post-login destination.
```
Token refresh is **reactive, not timer-based**: when any API call returns 401, the API client runs a single-flight refresh (`POST /api/v1/identity/token/refresh`) and retries the original request. At boot, if the stored access token is missing or expired but a refresh token exists, the auth context attempts one silent refresh before rendering protected routes. If a refresh fails (refresh token expired / revoked), the user is signed out and redirected to `/login`.
### Inactivity auto sign-out
The admin console signs operators out after **10 minutes** of idle time, preceded by a **60-second** warning countdown. Both durations come from `/config.json` (`inactivityIdleMs`, `inactivityWarningMs`), and the last-activity timestamp is shared across tabs via a localStorage key (`fsh.lastActivity`) so one active tab keeps every tab alive.
## Permission-gated UI
Endpoints are gated server-side by `.RequirePermission()`. The admin console mirrors that gating client-side so users don't see actions they can't perform:
```tsx
import { useAuth } from '@/auth/use-auth';
const { user } = useAuth();
const can = (perm: string) => user?.permissions.includes(perm) ?? false;
return (
<>
{can(IdentityPermissions.Users.Update) && }
{can(IdentityPermissions.Impersonation.Start) && }
>
);
```
The JWT only carries role names — effective permissions are resolved server-side and fetched from `GET /api/v1/identity/permissions` after sign-in, then cached on the auth context. If the user gains a permission server-side (an admin updates their role), `refreshPermissions()` picks it up without a re-login. The permission string constants live in `clients/admin/src/lib/permissions.ts`, mirroring the server registries.
For whole-route gating, wrap the route's element in ``:
```tsx
{
path: "impersonation",
element: (
),
}
```
Unauthorised users see a `` instead of the route content. While the permission fetch is still in flight, the guard renders a quiet loading state rather than flashing "access denied".
## API integration
`clients/admin/src/api/` carries one file per backend module. Each file exposes typed functions wrapping the relevant endpoints with `apiFetch` — the kit's hand-written `fetch` wrapper (no codegen, no Axios):
```ts
// clients/admin/src/api/impersonation.ts
import { apiFetch } from "@/lib/api-client";
export function startImpersonation(input: StartImpersonationInput): Promise {
return apiFetch("/api/v1/identity/impersonation/start", {
method: "POST",
body: JSON.stringify(input),
});
}
```
Pages consume them via TanStack Query:
```tsx
const startMut = useMutation({
mutationFn: startImpersonation,
onSuccess: (response) => { /* open new tab with the impersonation token */ },
});
```
`apiFetch` (`clients/admin/src/lib/api-client.ts`):
- Adds the `Authorization: Bearer ...` header from the token store.
- Adds the `tenant` header (the stored tenant, or the configured default) — callers can override it to scope a request to a specific tenant, which the admin's branding editor uses.
- Handles 401 by running a **single-flight** token refresh and retrying the original request once; if refresh fails, the session is cleared.
- Applies a 30-second default timeout via `AbortSignal`, merged with any caller-supplied signal.
- Surfaces ProblemDetails responses as a typed `ApiRequestError` (status + title + detail + field `errors`) that React Query can render in the UI.
## Realtime — notifications
`clients/admin/src/realtime/realtime-context.tsx` opens one shared SignalR connection to `/api/v1/realtime/hub` after sign-in (the SignalR client is dynamically imported so unauthenticated pages never download it). Components subscribe through a hook instead of touching the connection directly:
```tsx
// clients/admin/src/components/notifications/notification-bell.tsx (simplified)
useRealtimeEvent("NotificationCreated", (notification) => {
queryClient.setQueryData(["notifications"], (prev) =>
prev ? [notification, ...prev] : [notification]);
toast.info(notification.title);
});
```
The admin console only wires `NotificationCreated` today — the dashboard's variant of the same context carries the chat + presence traffic. A bell icon in the top bar shows unread count + a popover with the recent items.
## The impersonation flow in the UI
This is the admin console's most-asked-about feature. Full walkthrough lives in the [operator impersonation guide](/docs/guides/operator-impersonation/); short version:
1. Find the user via **Users → search**.
2. Open the user detail page; click **Impersonate**.
3. Fill duration + reason; click **Start**.
4. A new tab opens at the tenant dashboard, authenticated as the impersonated user. The short-lived token is handed off via the URL hash — the admin app never installs it locally.
5. Use the dashboard exactly as the user would.
6. Return to admin; **revoke the grant** (from the Impersonation page or the inline active-grants card) to end the session server-side. The dashboard tab loses the session on its next API call and lands on a graceful "impersonation ended" page.
## Tenant branding editor
The tenant detail page carries a branding card where root operators edit a tenant's `TenantTheme` — the light + dark colour palettes and brand asset URLs (logo, dark logo, favicon). Typography and layout fields exist on the server-side DTO but are intentionally left out of the v1 editor. The admin console itself stays themed in the kit's own palette (no per-tenant chrome for operator tools).
See [Theming](/docs/frontend/theming/) for the full palette + asset surface.
## Notable pages
### Audits
A full audit query UI with filters and a debounced search box; each row opens a detail side sheet with the captured payload (masked fields stay masked).
### Roles + permission editor
Permission assignment per role, grouped by resource with per-action toggles and a bulk group toggle.
### Webhooks
Subscription list with delivery log per subscription — every attempt, response code, error message.
## Local development
```bash
cd clients/admin
npm install
npm run dev # http://localhost:5173 with HMR
```
The Vite dev server proxies `/api`, `/health`, `/openapi`, and `/scalar` to the backend — `http://localhost:5030` by default, overridable with a `VITE_API_BASE_URL` env var (this var only steers the **dev proxy target**; it is never baked into a build). Runtime configuration — API base URL, default tenant, inactivity timings — comes from `public/config.json`, fetched once at boot before React mounts.
The kit's Aspire AppHost wires all of this automatically when you run `dotnet run --project src/Host/FSH.Starter.AppHost` — the API, admin, and dashboard all come up together.
## Build + deploy
```bash
npm run build # tsc -b + vite build → dist/ contains static assets
```
The `dist/` folder is a static site — drop it on any static host (Nginx, Vercel, Netlify, S3+CloudFront). `clients/admin/Dockerfile` ships an Nginx-based container that serves the built assets and renders `/config.json` from environment variables (`FSH_API_URL`, `FSH_DEFAULT_TENANT`, `FSH_DASHBOARD_URL`) via `envsubst` at startup — one image, every environment. See [Local development](/docs/frontend/development/).
## Related
- [Tenant dashboard](/docs/frontend/dashboard/) — the end-user-facing companion.
- [Frontend architecture](/docs/frontend/architecture/) — shared patterns.
- [Operator impersonation guide](/docs/guides/operator-impersonation/) — end-to-end UI walkthrough.
- [Identity module](/docs/modules/identity/) — the API the admin console wraps.
---
# Frontend architecture
> Shared patterns across the admin + dashboard apps — folder layout, API client, route guards, realtime context, error handling, ProblemDetails parsing.
Source: https://fullstackhero.net/docs/frontend/architecture/
Both `clients/admin` and `clients/dashboard` follow the same folder layout, the same auth model, the same API client conventions, and the same realtime patterns. They're separate apps because their audiences are separate, but the patterns shouldn't surprise you when you move between them.
If you're forking only the dashboard (deleting admin), copy admin's audit-log viewer + role editor patterns into a tenant-admin-page in your dashboard if you ever need them. The patterns transfer; the audience separation doesn't have to.
## Folder layout
```
clients/{app}/
├── package.json React 19, Vite 7, TS 5.x, TanStack Query, Radix, etc.
├── vite.config.ts Vite + React + Tailwind plugins + dev proxy config
├── tsconfig.json
├── index.html Single-page-app shell
├── playwright.config.ts Route-mocked E2E test config (tests/ alongside)
├── Dockerfile + docker/ Nginx runtime image + config.json template
├── public/ Static assets + config.json (runtime config)
└── src/
├── main.tsx Loads /config.json, then createRoot +
├── App.tsx Top-level provider stack + RouterProvider
├── routes.tsx createBrowserRouter config (lazy routes)
├── env.ts Runtime config loader + typed accessor
├── api/ One file per backend module — typed apiFetch wrappers
├── auth/ JWT, token store, auth context, route guards, inactivity
├── components/ Shared components (layout, primitives, theme)
├── hooks/ Shared hooks (e.g. use-file-upload)
├── lib/ Shared helpers — api-client, query-client, cn
├── pages/ One folder (or file) per feature area
├── realtime/ SignalR context + hooks
└── styles/ Global CSS + Tailwind base
```
The dashboard adds `sse/` (Server-Sent Events context for the activity feed).
## The provider stack
```tsx
// App.tsx (both apps)
{/* admin: here; dashboard: a command-palette provider */}
```
Order matters:
1. Before any of this renders, `main.tsx` top-level-awaits `loadRuntimeConfig()` so `/config.json` is in memory before the first component reads `env.*`.
2. `ThemeProvider` outermost — light/dark/appearance applies even to the login screen.
3. `QueryClientProvider` so every child can use TanStack Query.
4. `AuthProvider` — contexts below depend on the token store + signed-in user.
5. Admin mounts its `RealtimeProvider` here; the dashboard mounts `RealtimeProvider` + `SseProvider` inside the authenticated `AppShell` instead, so public pages never open (or download) the realtime stack.
6. The router + `Toaster` come last.
## The API client
`clients/{app}/src/lib/api-client.ts` exports `apiFetch()` — a hand-written wrapper over the browser's `fetch`. No Axios, no generated client; the typed request/response shapes live next to each module's functions in `src/api/`.
```ts
// Simplified — the real thing is ~160 lines
export async function apiFetch(path: string, init: RequestInitEx = {}): Promise {
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${tokenStore.getAccessToken()}`);
headers.set("tenant", tokenStore.getTenant() ?? env.defaultTenant);
let response = await fetch(`${env.apiBase}${path}`, { ...init, headers, signal });
if (response.status === 401 && tokenStore.getRefreshToken()) {
await refreshAccessToken(); // single-flight — concurrent 401s share one refresh
response = await fetch(/* retry original request with the fresh token */);
}
if (!response.ok) {
const problem = await parseError(response); // ProblemDetails body, if JSON
throw new ApiRequestError(response.status, problem?.title ?? response.statusText, problem);
}
return response.json() as Promise;
}
```
What it handles for every call:
- **Auth + tenant headers** from the token store (callers can override `tenant` per request, or pass `skipAuth` for anonymous endpoints like login).
- **401 → single-flight refresh → retry.** Concurrent 401s share one in-flight refresh promise; if the refresh fails, tokens are cleared and the UI flips to `/login`.
- **Timeouts** — a 30-second default via `AbortSignal.timeout`, merged with any caller-supplied signal.
- **Typed errors** — non-2xx responses throw `ApiRequestError`, which carries the HTTP status plus the parsed ProblemDetails payload.
React components can branch on it:
```tsx
const { isError, error } = useQuery(...);
if (isError && error instanceof ApiRequestError) {
return ;
}
```
For validation errors (400 with an `errors` field), render the field errors inline:
```tsx
if (mutation.isError && mutation.error instanceof ApiRequestError) {
const fieldErrors = mutation.error.problem?.errors ?? {};
// fieldErrors.Sku?: string[] — render next to the input
}
```
## TanStack Query conventions
One file per backend module under `src/api/`, exporting typed functions:
```ts
// clients/admin/src/api/users.ts (simplified excerpt)
export async function searchUsers(params: SearchUsersParams = {}): Promise> {
const qs = new URLSearchParams(/* ...params... */);
return apiFetch>(`/api/v1/identity/users?${qs}`);
}
export async function getUser(id: string): Promise {
return apiFetch(`/api/v1/identity/users/${id}`);
}
export async function registerUser(input: RegisterUserInput): Promise {
return apiFetch(`/api/v1/identity/users/register`, {
method: "POST",
body: JSON.stringify(input),
});
}
```
Pages consume via React Query:
```tsx
const usersQuery = useQuery({
queryKey: ['users', { search, page }],
queryFn: () => searchUsers({ search, page }),
});
const createMut = useMutation({
mutationFn: registerUser,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
```
The `queryKey` is the canonical cache key. The kit uses arrays with one filter object per key entry — TanStack Query handles the hashing.
For mutations that touch multiple resource caches (e.g. deleting a user that affects roles + groups), invalidate them all in `onSuccess`.
TanStack Query reads the mutation options at **execute time**. If `onMutate` (or anything else) `setState`s a value that `mutationFn` closes over, the request can fire with the post-setState value — a race that bites exactly when you batch state updates before mutating. The kit's rule: everything a mutation needs travels as the argument to `mutate(arg)` / `mutationFn(arg)`; component state is never read inside the mutation callbacks.
## Route guards
Two layers:
```tsx
// Both apps: ProtectedRoute wraps the whole authenticated tree.
// "Are you signed in?" — if not, redirect to /login.
{
element: ,
children: [{ element: , children: [/* all app routes */] }],
}
// Admin only: RouteGuard wraps individual route elements.
// "Do you hold these permissions?" — if not, render .
{
path: "tenants",
element: (
),
}
```
The guard mirrors the exact permission the server endpoint enforces — the constants in `src/lib/permissions.ts` track the server registries. The dashboard doesn't wrap routes; it gates navigation entries and in-page actions against the fetched permission list instead (e.g. the recycle-bin tabs + nav entry only render for users holding a restore/view-trash permission). Either way the server stays the real enforcement layer; the client gating is UX.
## Realtime context
`src/realtime/realtime-context.tsx` (in both apps) owns one shared SignalR connection to `/api/v1/realtime/hub`. Components never see the raw `HubConnection` — the context exposes a small surface:
```tsx
type RealtimeContextValue = {
status: "idle" | "connecting" | "connected" | "reconnecting" | "error";
/** Wire a handler for a server event. Returns an unsubscribe function. */
on: (event: string, handler: (payload: T) => void) => () => void;
/** Dashboard only: invoke a hub method, e.g. invoke("Typing", channelId). */
invoke: (method: string, ...args: unknown[]) => Promise;
};
// Most components use the convenience hook:
useRealtimeEvent("NotificationCreated", (n) => { /* ... */ });
```
Implementation details worth knowing:
- The `@microsoft/signalr` package is **dynamically imported** on first connect, so pages without a realtime consumer never download it (~37 KB gzip).
- The provider subscribes to the token store — login, refresh-token rotation, and impersonation swaps all rebuild the connection with the fresh token.
- Reconnects use SignalR's automatic-reconnect backoff plus an outer retry loop with jitter, capped at 60 s.
- Transport is auto-negotiated (WebSockets → SSE → long-polling) for proxy-hostile networks.
One connection per user session; every subscriber attaches via `on(event, handler)` and the returned function detaches it. The dashboard's chat page is the canonical user — see the [dashboard page](/docs/frontend/dashboard/#chat--the-realtime-showcase).
## Error handling
The `ApiRequestError` shape (from `clients/{app}/src/lib/api-client.ts`):
```ts
export class ApiRequestError extends Error {
readonly status: number; // HTTP status
readonly problem?: ApiError; // parsed ProblemDetails payload
}
export type ApiError = {
status: number;
title?: string;
detail?: string;
errors?: Record; // field validation errors
[key: string]: unknown;
};
```
Components render errors via page-level error bands, inline field errors next to inputs, or a toast (sonner) for ephemeral failures that don't need persistent UI.
## Runtime configuration
Neither app bakes environment values into the bundle. Instead, `main.tsx` fetches `/config.json` **before mounting React**:
```ts
// main.tsx (both apps)
await loadRuntimeConfig(); // fetch("/config.json") → cached
createRoot(rootElement).render();
// anywhere else
import { env } from "@/env";
env.apiBase // "" in production (same-origin), or an absolute URL
env.defaultTenant // tenant header fallback, default "root"
env.inactivityIdleMs // idle auto-logout timing
```
In dev, Vite serves `public/config.json`; in production, the Nginx container renders it from environment variables via `envsubst` at startup. Same image, every environment. The admin app additionally carries `dashboardUrl` (for the impersonation handoff); the dashboard carries `demoMode` (login-page demo-account picker). See [development](/docs/frontend/development/).
### Inactivity auto sign-out
Both apps auto-sign-out idle sessions: admin after 10 minutes, dashboard after 20, each with a 60-second warning countdown — all four numbers configurable per deploy in `config.json`. The last-activity timestamp is shared across tabs via the `fsh.lastActivity` localStorage key so activity in any tab keeps the whole session alive.
## What both apps don't do
- **No Redux / Zustand / global state manager.** TanStack Query is the server state; React's `useState` / `useReducer` is the local state. Avoid pulling in a global store unless you have a genuine cross-page client-state need.
- **No CSS-in-JS.** Tailwind utilities + a global stylesheet (plus the odd per-feature CSS file, like chat's). The kit's design language stays consistent through utility classes.
- **No API client codegen.** The `src/api/` wrappers are written by hand against the OpenAPI doc — fewer moving parts than a generator, and the types only cover what the UI actually uses.
- **No microfrontends.** Both apps are single-bundle SPAs (with per-route lazy chunks). Module federation is appealing in slide decks; it's a real maintenance tax in practice.
## Related
- [Admin console](/docs/frontend/admin/) — operator-facing surface.
- [Tenant dashboard](/docs/frontend/dashboard/) — end-user surface.
- [Theming](/docs/frontend/theming/) — per-tenant brand customisation.
- [Local development](/docs/frontend/development/) — running, building, deploying.
---
# Tenant dashboard
> The end-user-facing React + Vite app at clients/dashboard — catalog, chat, files, tickets, invoices, identity admin, plus real-time SignalR + SSE feeds.
Source: https://fullstackhero.net/docs/frontend/dashboard/
`clients/dashboard` is what your tenants' users see when they log in. Catalog browsing, realtime chat, file uploads, ticket filing, invoices and subscription, tenant-scoped identity administration, profile management. It's the surface your customers spend most of their time in, so it's the one to invest UX care in first.
## Tech stack
Same shape as the [admin console](/docs/frontend/admin/) — React 19 + Vite 7 + TypeScript + TanStack Query v5 + React Router 7 + Tailwind 4 + Radix + Lucide. Differences worth knowing:
| | Used for |
|---|---|
| `clients/dashboard/src/realtime/` | SignalR subscriptions (chat, notifications, presence) |
| `clients/dashboard/src/sse/` | Server-Sent Events — the live activity feed on Overview + Activity |
| `cmdk` | Command palette (Ctrl/Cmd-K) |
| `@tanstack/react-virtual` | Virtualised long lists (chat message history) |
The dashboard does **not** use react-hook-form or zod — those are admin-only; dashboard forms are plain controlled components. It subscribes to **more** realtime than admin — chat is a continuous-conversation surface, so SignalR is on the hot path.
Like the admin app, the dashboard auto-signs-out idle sessions — **20 minutes** idle plus a **60-second** warning, configured per-deploy in `/config.json`.
## Page inventory
```
clients/dashboard/src/pages/
├── overview.tsx Landing page — stats + live activity
├── activity.tsx Full-page SSE activity feed
├── auth/ Forgot/reset password, email confirmation (+ login.tsx)
├── catalog/ Products, brands, categories, product detail
├── chat/ Channels, DMs, threads, mentions, reactions, pinning, search
├── files/ My files — presigned upload, list, download, delete
├── identity/ Tenant-scoped admin: users, roles, groups (+ detail pages)
├── invoices.tsx Invoice list + invoice-detail.tsx
├── subscription.tsx Current plan + billing status
├── settings/ Profile, security (2FA), appearance, notifications, API keys
├── system/ Sessions, trash (recycle bin) — plus health + audits routes
├── tickets/ File ticket, list, detail, comments
├── impersonation-ended.tsx Graceful landing when an operator grant ends
└── tenant-deactivated.tsx Terminal page when the tenant is deactivated mid-session
```
Plus shared chrome (sidebar, top bar, notification bell, command palette) in `clients/dashboard/src/components/`. Routes are lazy-loaded per page (`clients/dashboard/src/routes.tsx`).
A few notes:
- **Settings → API keys** is an honest "coming soon" placeholder, not a hidden 404.
- **System → Trash** is the cross-module recycle bin (products, brands, categories, tickets, files). Its tabs — and the nav entry itself — are permission-gated client-side to mirror the server's restore/view-trash permissions, so users never click into a guaranteed 403.
- The dashboard has no per-route `RouteGuard` like admin; auth is enforced by `ProtectedRoute`, and permission checks gate navigation entries + in-page actions.
## Appearance + theming
The dashboard's appearance system is **per-user**: light/dark/system mode, an accent colour (presets or a custom hue), font, density, and reduced motion — all stored in localStorage and applied via CSS custom properties (Settings → Appearance). The neutral chassis is deliberately untinted so the accent does the branding work.
Per-**tenant** branding lives server-side as the Multitenancy module's `TenantTheme` (palette, brand assets, typography, layout) with a permission-gated editor in the admin console. Full details: [theming](/docs/frontend/theming/).
## Chat — the realtime showcase
`clients/dashboard/src/pages/chat/` is the densest page in either frontend. Slack-style channels with:
- Three channel kinds — `Channel` (named), `DirectMessage` (1:1), `GroupMessage` (3+).
- Message threads with reply count badges (replies hang off a `parentMessageId`).
- Emoji reactions (toggle on/off, one row per emoji + count).
- Pinned messages with a dedicated panel.
- `@mention` autocomplete via `mention-picker.tsx` (queries the tenant's users as you type).
- Typing indicators — the hub throttles per channel + user (~3 s); the client auto-clears stale markers.
- Cursor-paginated message history with virtualised infinite scroll.
- Real-time updates via SignalR — new messages, edits, deletes, reactions, channel reads.
- Message search via `/api/v1/chat/search`.
The chat page is the canonical example of how to build realtime UI against the kit's hub. Components don't touch the `HubConnection` directly — they subscribe through the realtime context:
```tsx
// Simplified — see clients/dashboard/src/pages/chat/ for the real thing
useRealtimeEvent("ChatMessageCreated", (msg) => {
queryClient.setQueryData(/* append to the channel's message cache */);
});
// Hub methods (e.g. typing notifications) go through invoke():
const { invoke } = useRealtime();
void invoke("Typing", channelId);
```
`useRealtimeEvent(event, handler)` and `useRealtime()` come from `realtime-context.tsx`, which owns one shared connection. One connection, many subscriptions.
## Catalog — products, brands, categories
A product management surface over the Catalog module: paginated product list with search, brand + category list pages, and a product detail page with image gallery and an edit dialog.
## Files — presigned upload flow
`clients/dashboard/src/pages/files/` (with the orchestration in `src/hooks/use-file-upload.ts`) implements the kit's three-step presigned upload:
1. Browser calls `POST /api/v1/files/upload-url` with metadata (file name, size, content type, category) — the server mints a presigned PUT URL and reserves a `FileAsset` row.
2. Browser uploads bytes directly to MinIO / S3 via the presigned URL — no proxy through the API — with real upload progress.
3. Browser calls `POST /api/v1/files/{id}/finalize` — the server verifies the object and flips the file from `PendingUpload` to `Available`.
## Tickets — file + track support requests
Filing tickets from the dashboard, with priority + (optional) assignee + comments thread.
## Identity — tenant-scoped administration
`pages/identity/` is where a tenant's own admins manage **their** users, roles, and groups — list + detail pages for each, including a role permission editor driven by the server's permission catalog (`GET /api/v1/identity/permissions/catalog`), so new module permissions appear automatically.
Self-service for the signed-in user lives under **Settings** instead:
## Settings — profile, security, appearance, notifications
Five tabs: Profile, Security (password + 2FA), Appearance (mode / accent / font / density / reduced motion), Notification preferences, and an API-keys placeholder.
## Realtime architecture
Two transports, both mounted inside the authenticated shell (`app-shell.tsx`), so unauthenticated pages never pay for them:
**SignalR** — `clients/dashboard/src/realtime/realtime-context.tsx` owns one shared connection to `/api/v1/realtime/hub`:
```tsx
// Subscribe in a component — returns/cleans up automatically
useRealtimeEvent("NotificationCreated", (n) => { /* ... */ });
```
The context handles:
- Lazy-loading the SignalR client bundle on first connect.
- Token-aware rebuilds — login, refresh rotation, and impersonation switches all force a reconnect with the fresh token.
- Automatic reconnect with backoff + jitter (capped at 60 s), with status (`idle / connecting / connected / reconnecting / error`) surfaced as a state pill.
- Transport negotiation (WebSockets, then SSE, then long-polling) for proxy-hostile networks.
**SSE** — `clients/dashboard/src/sse/sse-context.tsx` streams the live activity feed. The client first exchanges its JWT for a short-lived stream token (`POST /api/v1/sse/token`), then opens `GET /api/v1/sse/stream?token=...` and parses the `text/event-stream` itself (fetch + ReadableStream, with reconnect/backoff). Overview and the Activity page render the same event buffer.
## Local development
```bash
cd clients/dashboard
npm install
npm run dev # http://localhost:5174 with HMR
```
The Vite dev server proxies `/api` (with WebSocket forwarding for SignalR), `/health`, `/openapi`, and `/scalar` to the backend — `https://localhost:7030` by default, overridable with a `VITE_API_BASE_URL` env var (dev proxy target only; runtime config comes from `/config.json`). In dev, Vite serves a `config.json` that points the long-lived SSE/SignalR streams straight at the API instead of through the proxy, so they don't starve the browser's per-host connection budget.
The dashboard's `config.json` also carries a `demoMode` flag that surfaces a demo-account picker on the login page — a runtime flag, so a single build can enable it in staging and omit it in production.
When run via the kit's Aspire AppHost, the dashboard wires automatically — the API, admin, and dashboard all come up with one command.
## Related
- [Admin console](/docs/frontend/admin/) — operator-facing companion.
- [Frontend architecture](/docs/frontend/architecture/) — shared patterns.
- [Theming](/docs/frontend/theming/) — per-tenant brand customisation.
- [Chat module](/docs/modules/chat/) — the API the chat surface wraps.
- [Files module](/docs/modules/files/) — the API the upload flow wraps.
---
# Local development
> Running the React frontends with Vite HMR, building for production, deploying to static hosts or behind Nginx, runtime config via /config.json.
Source: https://fullstackhero.net/docs/frontend/development/
Both `clients/admin` and `clients/dashboard` are Vite-based React SPAs. Standard `npm` lifecycle, standard build output, no special tooling required.
`dotnet run --project src/Host/FSH.Starter.AppHost` brings up Postgres, Valkey, MinIO, the API, the admin, and the dashboard — all at once, with service discovery handling URLs. You don't need to run `npm run dev` separately unless you're isolating frontend work.
## Running locally
### Via Aspire (recommended)
```bash
dotnet run --project src/Host/FSH.Starter.AppHost
```
This starts everything:
- API at https://localhost:7030 (http on 5030)
- Admin at http://localhost:5173
- Dashboard at http://localhost:5174
- Postgres at tcp/5432, Valkey at tcp/6379, MinIO at :9000
Vite HMR works through Aspire — edits to frontend files reload instantly in the browser.
### Via npm (frontend-only)
```bash
cd clients/admin # or clients/dashboard
npm install # first time only
npm run dev # http://localhost:5173 (dashboard: 5174)
```
This requires the backend to be running separately (either via Aspire, docker-compose, or `dotnet run` against the API project).
The Vite dev server proxies API paths to the backend — `http://localhost:5030` for admin, `https://localhost:7030` for the dashboard by default. To point at a different backend, set `VITE_API_BASE_URL` when starting the dev server; it only steers the **dev proxy target**, it is never compiled into a build.
## The npm scripts
```jsonc
// clients/admin/package.json (dashboard is identical apart from ports)
{
"scripts": {
"dev": "vite --port 5173", // dev server with HMR
"build": "tsc -b && vite build", // typecheck + production bundle
"preview": "vite preview --port 4173",// serve the production bundle locally
"lint": "eslint .",
"test:e2e": "playwright test", // route-mocked E2E suite
"test:e2e:ui": "playwright test --ui"
}
}
```
The `build` script runs the TypeScript project-references compiler first (`tsc -b`) so type errors fail the build. Vite handles transpilation + bundling.
## Production build
```bash
cd clients/admin
npm run build
```
Output:
```
clients/admin/dist/
├── index.html
├── assets/
│ ├── index-{hash}.js
│ ├── index-{hash}.css
│ └── ...
└── ...
```
`dist/` is a static site — pure HTML / JS / CSS. No server runtime required.
## Runtime configuration (`/config.json`)
Vite hardcodes `VITE_*` env vars into the bundle at build time. That's fine for static URLs, but you usually want **one built image** that runs against multiple environments (dev, staging, prod) with different API URLs.
The kit's solution: each app fetches `/config.json` once at boot — before React mounts — and reads every environment-dependent value from it. In dev, Vite serves `public/config.json`; in production, the container renders it from environment variables at startup.
```jsonc
// clients/admin/public/config.json (dev defaults)
{
"apiBase": "", // "" = same-origin (dev proxy / reverse proxy)
"defaultTenant": "root",
"dashboardUrl": "http://localhost:5174", // admin only — impersonation handoff target
"inactivityIdleMs": 600000,
"inactivityWarningMs": 60000
}
```
The dashboard's variant swaps `dashboardUrl` for a `demoMode` flag (login-page demo-account picker).
### The container pattern
`clients/{app}/Dockerfile` is a two-stage build — `node:22-alpine` runs `npm ci && npm run build`, then `nginx:alpine` serves `dist/`. The entrypoint renders the runtime config before starting Nginx:
```sh
# clients/{app}/docker/docker-entrypoint.sh
: "${FSH_API_URL:?FSH_API_URL is required (e.g. https://api.example.com)}"
: "${FSH_DEFAULT_TENANT:=root}"
envsubst < /usr/share/nginx/html/config.json.template \
> /usr/share/nginx/html/config.json
exec nginx -g 'daemon off;'
```
```jsonc
// clients/admin/docker/config.json.template
{
"apiBase": "${FSH_API_URL}",
"defaultTenant": "${FSH_DEFAULT_TENANT}",
"dashboardUrl": "${FSH_DASHBOARD_URL}"
}
```
At deploy time, set the env vars on the container:
```bash
docker run -e FSH_API_URL=https://api.example.com -e FSH_DASHBOARD_URL=https://app.example.com fsh-admin
```
The same image runs everywhere; only env vars differ.
### Reading runtime config in code
```ts
// main.tsx awaits this before mounting React
await loadRuntimeConfig(); // fetch("/config.json", { cache: "no-store" })
// everywhere else
import { env } from "@/env";
env.apiBase; env.defaultTenant; env.inactivityIdleMs;
```
`env.*` throws if read before the config loads — a deliberate fail-fast so a misordered import surfaces immediately instead of silently using the wrong API URL.
## Nginx configuration for SPAs
The kit's actual file, `clients/{app}/docker/nginx.conf`:
```nginx
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Long-lived caching for hashed asset bundles
location ~* \.(?:js|css|woff2?|png|jpg|jpeg|svg|ico|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Runtime config — must be re-fetched on every deploy
location = /config.json {
add_header Cache-Control "no-store";
add_header X-Content-Type-Options "nosniff";
}
# SPA fallback with security headers
location / {
try_files $uri /index.html;
add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options "nosniff";
add_header Referrer-Policy "strict-origin-when-cross-origin";
}
# Block dotfiles
location ~ /\. {
deny all;
}
}
```
## Deploying to a CDN
The kit's frontends are CDN-friendly. Three deployment shapes:
### Vercel / Netlify
Drag-drop the `dist/` folder, or connect the repo and configure:
- Build command: `cd clients/admin && npm install && npm run build`
- Output directory: `clients/admin/dist`
For runtime config, ship a per-environment `config.json` alongside the build output (it's just a static file), or generate it in the build command.
### S3 + CloudFront
1. `npm run build`, then `aws s3 sync dist/ s3://fsh-admin-bucket/`
2. CloudFront distribution in front, with a default-root-object of `index.html` + a custom error response that serves `index.html` for 404 (so SPA routing works).
3. Set cache TTLs: no-store on `config.json`, short on `index.html`, long on the hashed assets.
### Cloudflare Pages
Connect the repo, configure build command + output directory. Cloudflare's Workers can inject runtime env via Pages Functions if needed.
## Testing
Both apps ship Playwright E2E suites — fully **route-mocked** (no live backend required) with seeded JWTs, so they run fast and deterministic in CI:
```bash
npm run test:e2e # headless run
npm run test:e2e:ui # Playwright's interactive UI mode
```
Tests live under `clients/{app}/tests/`. There is no unit-test runner (Vitest) wired — the coverage strategy is E2E against mocked routes plus the backend's own xUnit + integration suites.
## Linting
ESLint is wired (flat config at `clients/{app}/eslint.config.js`, extending the React + TypeScript + a11y rule sets):
```bash
npm run lint # report
npm run lint -- --fix # auto-fix
```
## The dev server proxy
Vite proxies API paths to the backend so you don't deal with CORS during development:
```ts
// clients/dashboard/vite.config.ts (excerpt)
server: {
port: 5174,
strictPort: true,
proxy: {
// ws: true forwards the WebSocket upgrade used by SignalR's hub
"/api": { target: apiBase, changeOrigin: true, secure: false, ws: true },
"/health": { target: apiBase, changeOrigin: true, secure: false },
"/openapi": { target: apiBase, changeOrigin: true, secure: false },
"/scalar": { target: apiBase, changeOrigin: true, secure: false },
},
},
```
With this in place, relative URLs (`/api/v1/...`) work in dev, matching the production same-origin default (`"apiBase": ""`). One nuance: the dashboard's dev server serves a `config.json` whose `apiBase` points **directly** at the API, so the long-lived SSE + SignalR streams bypass the proxy — otherwise they pin connections to `localhost:5174` and HTTP/1.1's ~6-per-host cap can starve lazy route-chunk loads.
## Common issues
- **CORS errors in dev** — your proxy isn't picking up the right backend URL. Set `VITE_API_BASE_URL` when starting `npm run dev`, or fix the backend's CORS config if you're hitting it directly.
- **SignalR fails to connect after deploy** — your reverse proxy isn't forwarding WebSocket upgrade headers. Add `proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";` to Nginx.
- **App boots to an error about `/config.json`** — the runtime config file is missing or unreachable. In containers, check the `FSH_*` env vars (the entrypoint refuses to start without `FSH_API_URL`); on static hosts, make sure `config.json` deployed next to `index.html`.
- **HMR not updating in Aspire** — `dotnet watch` doesn't always proxy WebSocket upgrades for Vite. Run `npm run dev` separately in that case.
## Related
- [Admin console](/docs/frontend/admin/) — the operator app.
- [Tenant dashboard](/docs/frontend/dashboard/) — the end-user app.
- [Frontend architecture](/docs/frontend/architecture/) — shared patterns.
- [Deployment](/docs/deployment/) — production wiring across services.
---
# Per-tenant theming
> How tenant branding works in the kit — the TenantTheme aggregate in the Multitenancy module, the admin branding editor, and the dashboard's per-user appearance system.
Source: https://fullstackhero.net/docs/frontend/theming/
Theming in the kit has two layers that are easy to conflate, so let's name them up front:
1. **Per-tenant branding** — the Multitenancy module owns a `TenantTheme` aggregate (colour palettes, brand assets, typography, layout) with permission-gated read/update/reset endpoints and an editor in the admin console. Themes are **data, not deploys** — editing one never requires a rebuild.
2. **Per-user appearance** — the dashboard ships a rich appearance system (light/dark/system mode, accent colour, font, density, reduced motion) that each **user** controls from Settings → Appearance and that persists in localStorage.
The `TenantTheme` store + editor are fully wired end-to-end on the backend and admin side. The dashboard does **not yet** read `TenantTheme` to repaint its chrome per tenant — its visual identity today comes from the per-user appearance system. If you want full white-label rendering, the wiring point is small and described [below](#rendering-tenanttheme-in-your-own-frontend).
## The `TenantTheme` model
The aggregate lives at `src/Modules/Multitenancy/Modules.Multitenancy/Domain/TenantTheme.cs`; the API exchanges a structured `TenantThemeDto` (light palette, dark palette, brand assets, typography, layout).
### Palette
Light mode + dark mode values for nine roles:
| Role | Default light | Default dark | Used for |
|---|---|---|---|
| Primary | `#2563EB` | `#38BDF8` | Buttons, links, accents |
| Secondary | `#0F172A` | `#94A3B8` | Secondary buttons, badges |
| Tertiary | `#6366F1` | `#818CF8` | Tertiary accents, dividers |
| Background | `#F8FAFC` | `#0B1220` | App background |
| Surface | `#FFFFFF` | `#111827` | Cards, sheets, dialogs |
| Error | `#DC2626` | `#F87171` | Destructive actions, error banners |
| Warning | `#F59E0B` | `#FBBF24` | Cautionary states |
| Success | `#16A34A` | `#22C55E` | Success states |
| Info | `#0284C7` | `#38BDF8` | Informational states |
### Brand assets
- `LogoUrl` — light-mode logo.
- `LogoDarkUrl` — dark-mode logo.
- `FaviconUrl` — browser tab icon.
Uploads go through the kit's storage service — the update endpoint accepts inline image data, stores it, and replaces (and cleans up) the previous asset.
### Typography
- `FontFamily` — body font (default `Inter, sans-serif`).
- `HeadingFontFamily` — display font for headings (default `Inter, sans-serif`).
- `FontSizeBase` — body font size in px (default 14).
- `LineHeightBase` — body line height (default 1.5).
### Layout
- `BorderRadius` — radius for cards, buttons, inputs (CSS string, default `4px`).
- `DefaultElevation` — shadow strength (default 1).
### Default theme
`IsDefault` marks one theme as the platform default. Only the **root tenant** can set it; any tenant without its own saved theme resolves to the kit's built-in defaults.
## The endpoints
All three are **current-tenant-scoped** — they act on whichever tenant the request's `tenant` header resolves to (there's no `{id}` in the route) — and permission-gated:
| Endpoint | Permission | Does |
|---|---|---|
| `GET /api/v1/tenants/theme` | `Tenants.ViewTheme` | Returns the tenant's theme, or the defaults if none is saved |
| `PUT /api/v1/tenants/theme` | `Tenants.UpdateTheme` | Upserts the theme (validated — colour formats, font sizes) |
| `POST /api/v1/tenants/theme/reset` | `Tenants.UpdateTheme` | Resets every field to the kit defaults |
Reads are cached via HybridCache (1 hour distributed / 2 minutes local) and invalidated on every update or reset, so theme fetches don't hit the database per request.
## Editing themes in the admin console
The branding card on the admin console's **tenant detail page** is the operator-facing editor. It scopes each call to the target tenant by overriding the `tenant` header — only root operators get past that override — and covers the light + dark palettes plus brand assets. Typography and layout exist on the DTO but are intentionally left out of the v1 editor; wire them in if your product needs them.
**Save** persists via `PUT /api/v1/tenants/theme`; **Reset** calls `POST /api/v1/tenants/theme/reset`. Both invalidate the server-side cache immediately.
## The dashboard's appearance system (per-user)
What a dashboard user can customise today is personal, not tenant-wide — Settings → Appearance:
- **Mode** — light / dark / system, applied with a View Transitions crossfade.
- **Accent** — preset accent colours or a fully custom hue, applied as the eleven `--brand-*` CSS custom-property stops.
- **Font, density, reduced motion** — per-user toggles.
Everything persists in localStorage (`fsh.theme`, accent/font/density keys) and applies before the app paints, so there's no flash of the wrong mode. The dashboard's neutral greys are deliberately **untinted** (zero chroma) so whatever accent the user — or eventually the tenant — picks does the branding work without fighting a tinted chassis.
## Rendering `TenantTheme` in your own frontend
The server side is done; consuming it is the part you own. The shape:
1. After login, call `GET /api/v1/tenants/theme` (the signed-in user needs `Tenants.ViewTheme`).
2. Map the DTO onto CSS custom properties on `document.documentElement` — palette roles to colour variables, typography to `--font-*`, `BorderRadius` to your radius token.
3. Swap the logo / favicon from `BrandAssets`.
4. Cache the last-applied theme in localStorage and re-apply it synchronously in `index.html` before React mounts, so returning users see their brand on first paint instead of a default flash.
Because every kit component already resolves colours through CSS variables, step 2 is a mapping exercise, not a redesign.
## What theming doesn't do
- **It's not a full white-label.** The component library (sidebar shape, navigation pattern, dialog conventions) stays the same. Theming changes colours, fonts, and assets; it doesn't change layout.
- **It doesn't theme the admin console.** Admin is for your operators; it stays in the kit's own palette so operators have a consistent surface across tenants.
- **It doesn't ship a theme marketplace.** Tenants get one theme each; there's no "browse themes" gallery. If you want that, add a `ThemeTemplate` aggregate with a one-click apply.
## Related
- [Multitenancy module](/docs/modules/multitenancy/) — the `TenantTheme` aggregate + endpoints.
- [Tenant dashboard](/docs/frontend/dashboard/) — the per-user appearance system.
- [Admin console](/docs/frontend/admin/) — the branding editor surface.
---
# Overview
> Install the kit, run it locally, and understand the project layout.
Source: https://fullstackhero.net/docs/getting-started/
Everything you need to spin up a fresh project, in order. Start with the **Introduction** for the mental model, then jump straight to **Quick Start** if you just want to run the thing.
---
# Demo accounts & seed data
> Every demo tenant, user persona, and piece of sample data the seed-demo verb creates — what you can log in as and what you'll see.
Source: https://fullstackhero.net/docs/getting-started/demo-accounts/
When the Aspire stack comes up, the dashboard login page (`http://localhost:5174`) shows a
**"Sign in with a demo account"** button. One click drops you into any persona — platform
owner, tenant admin, support agent, regular member — without typing a thing. This page is
the reference for what those accounts are and what data sits behind them.
Every password on this page is hardcoded in the repo, which means it's public knowledge.
`seed-demo` is dev-only by design: the migrator **refuses to run the verb unless the
environment is `Development`** and exits non-zero. Don't work around that guard, and don't
ship a production `config.json` with `demoMode: true` — the picker would advertise
credentials that (hopefully) don't exist there.
## Two layers of seeding
The DbMigrator does all seeding — the API never mutates data at startup. There are two
distinct layers, and it helps to keep them straight:
| | `apply --seed` (baseline) | `seed-demo` (demo content) |
|---|---|---|
| **When** | Every environment, including production | Dev only — hard-refuses elsewhere |
| **Tenants** | `root` only (on first run) | Adds `acme` and `globex` |
| **Users** | The tenant admin per tenant — `admin@root.com` on root | 10 Acme users, 1 Globex user, `superadmin@root.com`, plus password realignment |
| **Roles** | `Admin` + `Basic` with their permission claims, an Administrators group | Custom `Manager` + `Support` roles in Acme |
| **Billing** | Default plans: `free`, `pro`, `pro-annual` | Active subscriptions + a term invoice for paid plans |
| **Content** | None — fresh tenants start empty | Catalog, tickets, chat channels + messages |
| **Password source** | `Seed:DefaultAdminPassword` | `Seed:DemoPassword` |
So `admin@root.com` exists in *every* deployment (it comes from baseline seeding), while
everything else on this page only exists after `seed-demo`.
Both layers are idempotent — every step checks before writing, so re-running against an
already-seeded database is a no-op.
## The shared demo password
Every demo account signs in with:
```text
Password123!
```
It's sourced from `Seed:DemoPassword` (the Aspire AppHost sets it; the API's
`appsettings.Development.json` carries the same value). The seeder also **realigns the
tenant admin passwords** — `admin@root.com`, `admin@acme.com`, `admin@globex.com` — to this
shared password, so in the dev stack the root admin signs in with `Password123!` too.
A deployment that runs only `apply --seed` (i.e. any non-dev environment) keeps whatever
`Seed:DefaultAdminPassword` you configured for the root admin. The Aspire dev default for
that is `123Pa$$word!`, but it's irrelevant in dev because `seed-demo` overwrites it.
## The dashboard demo picker
The picker groups accounts by tenant, exactly as defined in
`clients/dashboard/src/pages/login.demo-accounts.ts`. Picking one fills tenant + email +
password and signs in instantly.
### Root — platform owner, super-tenant
| Login | Name | Role | Use it to… |
|---|---|---|---|
| `admin@root.com` | Root Admin | Tenant Admin | Act as the platform owner — manage tenants, billing plans, all permissions |
### Acme Corp — the populated tenant
Acme is where most flows make sense: full catalog, open tickets, busy chat.
| Login | Name | Role | Use it to… |
|---|---|---|---|
| `admin@acme.com` | Acme Admin | Tenant Admin | See everything a tenant admin can do — full access |
| `manager@acme.com` | Maya Lin | Manager (custom role) | Manage catalog + tickets with read-only users — a mid-tier custom role |
| `support@acme.com` | Sam Rivera | Support (custom role) | Work tickets only — watch most of the nav disappear |
| `alice@acme.com` | Alice Nguyen | Basic | Experience the default-member baseline and permission gating |
| `bob@acme.com` | Bob Patel | Basic | Same as Alice — handy for two-browser chat/DM testing |
### Globex — the sparse tenant
Globex is the "just onboarded" contrast: one extra user, two tickets, one quiet channel,
and the free plan.
| Login | Name | Role | Use it to… |
|---|---|---|---|
| `admin@globex.com` | Globex Admin | Tenant Admin | A tenant admin on the free plan with minimal activity |
| `dave@globex.com` | Dave Hartwell | Basic | A lone member in a near-empty tenant |
### Accounts that exist but aren't in the picker
The seeder creates more Acme members than the picker advertises — they exist to make chat
and tickets feel lived-in. You can log in as any of them manually with the shared password:
`carol@`, `dan@`, `erin@`, `frank@`, `gina@`, `henry@acme.com` (all Basic).
There's also `superadmin@root.com` (Admin role on the root tenant) — that's the account the
**admin app's** demo picker surfaces, see below.
## What `seed-demo` puts in each tenant
| Data | Acme Corp | Globex |
|---|---|---|
| Plan / subscription | `pro-annual` (active) + an issued term invoice | `free` (active), no invoice — free plans don't invoice, same as production |
| Users | Admin + 10 members, incl. the `Manager` and `Support` custom roles | Admin + 1 member |
| Catalog | 4 brands, 11 categories, 10 products | Same dataset (catalog seeds for both demo tenants) |
| Tickets | 6 tickets (`TK-1`–`TK-6`): 2 resolved, varied priorities, comments, assignees | 2 open tickets reported by Dave, unassigned |
| Chat | `#general` + `#random` (public), `#engineering` (private), and an Alice↔Bob DM, all with messages | `#general` with a single welcome message |
The custom roles are worth poking at — they're the demo for permission-driven UI:
| Role | What it grants |
|---|---|
| **Manager** | Full CRUD on brands, categories, products, and tickets; view + update users; view roles, groups, sessions; revoke sessions |
| **Support** | View, create, and update tickets; view users and sessions; revoke sessions — no catalog, no deletes |
Fresh tenants you create yourself get none of this — they start with an empty catalog and
populate via the API or admin UI.
## How seeding runs
### In the Aspire dev stack
`dotnet run --project src/Host/FSH.Starter.AppHost` runs the migrator twice as one-shot
jobs, and the API waits for both:
1. **Migrator** — `apply --seed`: migrations + baseline seed (root tenant, roles, `admin@root.com`, default plans).
2. **Demo seeder** — `seed-demo` with `DOTNET_ENVIRONMENT=Development`: everything on this page.
### Manually
```bash
dotnet run --project src/Host/FSH.Starter.DbMigrator -- seed-demo
```
Two things must be true for the verb to run:
- The environment must be `Development`. The migrator is a console host, so set
`DOTNET_ENVIRONMENT=Development` (it ignores `ASPNETCORE_ENVIRONMENT`).
- `Seed:DemoPassword` must be configured. The migrator links the API's
`appsettings.Development.json`, which already carries `Password123!` — so in the
Development environment this just works. Override with the
`Seed__DemoPassword` environment variable if you want a different credential.
The default connection string targets `localhost` Postgres; override
`DatabaseOptions__ConnectionString` if your database lives elsewhere. Run `--help` on the
migrator for the full verb/flag list.
## Turning the picker off
The two apps gate their pickers differently:
- **Dashboard** — a runtime flag: `"demoMode": true` in `clients/dashboard/public/config.json`.
It's runtime (not a `VITE_` build-time var) so a single build artifact can be promoted
across environments — set it `true` in staging's `config.json`, `false` or omit it in
production's. Omitted means **off**.
- **Admin** — gated on `import.meta.env.DEV`, so it only exists in dev builds
(`npm run dev`). A production build never includes it; there's nothing to configure.
It surfaces exactly one account: `superadmin@root.com` / `Password123!` on the `root`
tenant.
The picker is a static list in the frontend — it never calls the API (the login page is
unauthenticated, and the API shouldn't advertise credentials). If you add a demo account on
the backend, mirror it in `login.demo-accounts.ts` by hand.
## Related
- [Quick Start](/docs/getting-started/quick-start/) — bring the whole stack up with one command.
- [Operator impersonation](/docs/guides/operator-impersonation/) — what the root operator can do once signed in.
---
# Install
> Every way to get the kit — the fsh CLI, the dotnet new template, a git clone, and GitHub (Use this template / Codespaces).
Source: https://fullstackhero.net/docs/getting-started/install/
Make sure you've got everything in [Prerequisites](/docs/getting-started/prerequisites/) first. There are four ways to get the kit — pick based on whether you want a fresh renamed project, the raw source, or zero local install.
Both the `fsh` CLI (`FullStackHero.CLI`) and the `dotnet new` template
(`FullStackHero.NET.StarterKit`) are published as stable **10.0.0**. The
one-command flow — install the CLI, then `fsh new MyApp` — works end-to-end.
## Which path?
| Path | Renames the project | Best for |
|---|---|---|
| **fsh CLI** *(recommended)* | Yes | Starting a new app; interactive setup + environment check |
| **dotnet new template** | Yes | Scripted/CI scaffolding without the CLI |
| **git clone** | No | Reading the source, contributing, or running the kit as-is |
| **GitHub template / Codespaces** | Yes (via GitHub) | Zero local install, or your own repo from the template |
`fsh new` is the smoothest path: it scaffolds a fully renamed project, swaps in a
unique JWT signing key, generates strong Docker secrets, runs `npm install`, and
makes the first commit — the setup you'd otherwise do by hand.
## Path 1 — The fsh CLI (recommended)
A NuGet-distributed dotnet tool that scaffolds a fresh, fully renamed project from the latest template:
```bash
dotnet tool install -g FullStackHero.CLI
fsh doctor # verify .NET SDK, Git, Docker, the template, free ports
fsh new MyApp # interactive: include Aspire? include the React apps?
cd MyApp
dotnet run --project src/Host/MyApp.AppHost
```
Non-interactive (handy for scripts) — skips the prompts and uses defaults:
```bash
fsh new MyApp --non-interactive
```
See the [CLI reference](/docs/cli/) for every command (`new`, `doctor`, `info`, `update`) and flag.
## Path 2 — The dotnet new template
`fsh new` scaffolds from this template under the hood; you can also use it directly without the CLI — handy for scripted or CI scaffolding:
```bash
dotnet new install FullStackHero.NET.StarterKit
dotnet new fsh -n MyApp
cd MyApp
dotnet run --project src/Host/MyApp.AppHost
```
## Path 3 — Clone the repository
Get the full source as-is, with no project renaming (the solution keeps its `FSH.Starter` names):
```bash
git clone https://github.com/fullstackhero/dotnet-starter-kit.git MyApp
cd MyApp
dotnet restore src/FSH.Starter.slnx
dotnet run --project src/Host/FSH.Starter.AppHost
```
This is the path for reading the code, [contributing](/docs/contributing/), or running the kit unchanged.
## Path 4 — GitHub: Use this template / Codespaces
On the [GitHub repo](https://github.com/fullstackhero/dotnet-starter-kit), click **Use this template** to create your own repository from the kit — or **Open in Codespaces** for a zero-install dev environment (the devcontainer ships the .NET 10 SDK and Docker-in-Docker, restores the solution, and forwards the API ports).
First Codespaces boot takes a couple of minutes; subsequent ones are instant.
## Verify it's running
However you installed, the AppHost launches the Aspire dashboard. Every service should reach a healthy/running state, and the API serves OpenAPI + Scalar at `https://localhost:7030/scalar`.
## Next
- **[Quick Start](/docs/getting-started/quick-start/)** — the minimal scaffold-and-run walkthrough.
- **[Local Orchestration with Aspire](/docs/deployment/aspire/)** — what each service is and why it's wired that way.
- **[Architecture](/docs/architecture/)** — the modular monolith + vertical slice mental model.
---
# Introduction
> What fullstackhero is, what it ships with, and how it's put together.
Source: https://fullstackhero.net/docs/getting-started/introduction/
fullstackhero is an opinionated, production-first .NET 10 starter for building multi-tenant SaaS and enterprise APIs. You get the full source — BuildingBlocks, Modules, and a composition-root Host — wired through Minimal APIs, Mediator, and EF Core. No black-box NuGet packages: you own and can modify everything.
It's aimed at teams that want to ship features on day one without spending a sprint wiring identity, tenancy, auditing, caching, jobs, storage, observability, and API docs.
## Highlights
- **Modular monolith, vertical slices.** Each module is a bounded context with its own DbContext, contracts, and feature folders. Communication across modules goes through `*.Contracts` projects only — runtime projects never reference each other.
- **Multi-tenant from day one.** Finbuckle-powered tenancy across Identity and module DbContexts. Tenant resolution via claim, header, or query string. Per-tenant migrations and seeding run through a dedicated DbMigrator — the API never touches the schema at startup.
- **Identity + permission-based auth.** ASP.NET Identity with JWT issue/refresh, roles, granular permissions enforced via `.RequirePermission()` on endpoints.
- **Eventing built in.** Domain events on aggregates + integration events through Mediator. Outbox-friendly entity model.
- **Webhooks with HMAC signing.** Tenant-scoped subscriptions, retries, and delivery logs.
- **Realtime + SSE.** SignalR for hubs, Server-Sent Events for one-way streaming.
- **Idempotency.** `Idempotency-Key` header with cache-backed replay.
- **Feature flags with tenant overrides.** Microsoft.FeatureManagement, scoped per tenant.
- **Quotas.** Per-tenant request and resource limits.
- **Observability baked in.** OpenTelemetry traces, metrics, and logs (OTLP); Serilog structured logging; health checks; security/exception auditing.
- **API docs.** OpenAPI + Scalar UI. Versioned routes via `Asp.Versioning` (`/api/v{version}/{module}`).
- **Cloud-ready.** `.NET Aspire` AppHost brings up Postgres, Valkey (a Redis-compatible, BSD-licensed Redis fork), MinIO, the migrator, the API, and both React apps locally with one command. Production deploy via Docker Compose included.
- **Two frontends.** React + Vite admin console and dashboard, both in `clients/`.
- **CLI + template.** Scaffold a fresh, fully renamed project in one command with the `fsh` CLI (`FullStackHero.CLI`) or the `dotnet new fsh` template (`FullStackHero.NET.StarterKit`) — both on NuGet as stable 10.0.0. Or clone the repo / use the GitHub template.
## High-level architecture
```
dotnet-starter-kit/
├── src/
│ ├── BuildingBlocks/ Shared framework libraries
│ │ ├── Core DDD primitives, exceptions, abstractions
│ │ ├── Persistence EF Core base contexts, specifications, soft delete
│ │ ├── Web Host wiring: auth, OpenAPI, rate limit, CORS, errors
│ │ ├── Caching HybridCache + Valkey
│ │ ├── Eventing Domain + integration events
│ │ ├── Eventing.Abstractions Contracts for cross-module events
│ │ ├── Jobs Hangfire
│ │ ├── Mailing MailKit / SendGrid
│ │ ├── Storage Local + S3-compatible
│ │ ├── Quota Per-tenant limits
│ │ └── Shared Permission constants, common contracts
│ │
│ ├── Modules/ Bounded contexts (each = runtime + contracts pair)
│ │ ├── Identity ASP.NET Identity, JWT, roles, permissions
│ │ ├── Multitenancy Tenant CRUD, migrations, upgrades
│ │ ├── Auditing Activity / security / exception audit trail
│ │ ├── Webhooks HMAC-signed delivery, retries, logs
│ │ ├── Files Upload URLs, storage abstraction
│ │ ├── Notifications In-app notifications
│ │ └── Billing · Catalog · Chat · Tickets Sample modules
│ │
│ ├── Host/
│ │ ├── FSH.Starter.Api Composition root (Program.cs)
│ │ ├── FSH.Starter.AppHost .NET Aspire orchestrator
│ │ ├── FSH.Starter.DbMigrator Migrations + seed-demo CLI
│ │ └── FSH.Starter.Migrations.PostgreSQL
│ │
│ ├── Tools/CLI The `fsh` CLI (Spectre.Console)
│ └── Tests/ Architecture tests + per-module test projects
│
├── clients/
│ ├── admin React + Vite admin console
│ └── dashboard React + Vite dashboard
│
└── deploy/ Docker Compose, Dokploy, Terraform
```
Module boundaries are enforced by NetArchTest rules in `src/Tests/Architecture.Tests`.
### Feature slice layout
Each feature is a vertical slice. For `Modules.Identity` registering a user:
```
Modules.Identity.Contracts/v1/Users/RegisterUser/
RegisterUserCommand.cs
RegisterUserResponse.cs
Modules.Identity/Features/v1/Users/RegisterUser/
RegisterUserEndpoint.cs ← Minimal API
RegisterUserCommandHandler.cs ← Mediator handler
RegisterUserCommandValidator.cs ← FluentValidation
```
Handlers are `public sealed`, return `ValueTask`, and use `.ConfigureAwait(false)`. Endpoints are static extensions on `IEndpointRouteBuilder`.
### Composition root
`src/Host/FSH.Starter.Api/Program.cs` wires the platform in three calls:
```csharp
builder.Services.AddMediator(o => { o.Assemblies = [/* command + handler markers */]; });
builder.AddHeroPlatform(o =>
{
o.EnableCaching = true;
o.EnableMailing = true;
o.EnableJobs = true;
o.EnableQuotas = true;
o.EnableSse = true;
o.EnableRealtime = true;
});
builder.AddModules(moduleAssemblies);
var app = builder.Build();
app.UseHeroMultiTenantDatabases();
app.UseHeroPlatform(p => { p.MapModules = true; /* ... */ });
```
The API never mutates data on startup. Demo data (acme/globex tenants, sample catalog/tickets/chat) is provisioned by `FSH.Starter.DbMigrator seed-demo`, which Aspire runs automatically on launch (the `fsh-starter-demo-seeder` resource) — so the demo logins work out of the box.
## Stack
### Runtime
| Concern | Technology |
|---------------------|-------------------------------------------------------------------------|
| Framework | .NET 10 / C# latest |
| Solution | `.slnx` (XML) with central package management (`Directory.Packages.props`) |
| Web | Minimal APIs, `Asp.Versioning.Http` 10 |
| Mediator | Mediator 3.0.2 (source generator — no reflection) |
| Validation | FluentValidation 12 |
| ORM | EF Core 10 |
| Database | PostgreSQL (Npgsql) by default; SQL Server provider available |
| Identity | ASP.NET Identity + JWT bearer (`Microsoft.AspNetCore.Authentication.JwtBearer` 10) |
| Multitenancy | Finbuckle.MultiTenant 10 (claim / header / query strategies) |
| Caching | `HybridCache` (in-memory + Valkey) via `StackExchange.Redis` |
| Jobs | Hangfire 1.8 (Postgres or in-memory) |
| HTTP resilience | `Microsoft.Extensions.Http.Resilience` (Polly v8) |
| Feature flags | Microsoft.FeatureManagement + tenant overrides |
| Realtime | SignalR (Valkey backplane) + Server-Sent Events |
| Mail | MailKit / SendGrid |
| Storage | Local filesystem or S3-compatible (AWS SDK) |
| Messaging | RabbitMQ client (optional) |
| Logging | Serilog 4 (structured) |
| Tracing / metrics | OpenTelemetry 1.15 (OTLP exporter) |
| API docs | OpenAPI 10 + Scalar UI |
| Errors | `ProblemDetails` (RFC 9457) via global exception handler |
| Orchestration | .NET Aspire 13.4 (Postgres + Valkey + MinIO + API + both React apps) |
### Frontends
| App | Stack |
|-------------|--------------------------------|
| `clients/admin` | React + Vite |
| `clients/dashboard` | React + Vite |
### Testing
| Layer | Tools |
|------------------|------------------------------------------------------------------|
| Unit | xUnit, Shouldly, NSubstitute, AutoFixture |
| Integration | `Microsoft.AspNetCore.Mvc.Testing`, Testcontainers (Postgres / Valkey / Minio) |
| Architecture | NetArchTest — enforces module boundaries |
### Distribution
| Channel | Command |
|------------------------|--------------------------------------------------|
| Clone the repo | `git clone https://github.com/fullstackhero/dotnet-starter-kit.git` |
| GitHub Codespaces | "Use this template" → open in Codespaces |
| FSH CLI *(recommended)* | `dotnet tool install -g FullStackHero.CLI && fsh new MyApp` |
| `dotnet new` template | `dotnet new install FullStackHero.NET.StarterKit && dotnet new fsh -n MyApp` |
## Where to go next
- **[Prerequisites](/docs/getting-started/prerequisites/)** — what you need installed.
- **[Quick Start](/docs/getting-started/quick-start/)** — run a project in 60 seconds.
- **[Architecture](/docs/architecture/)** — modular monolith, VSA, module boundaries.
- **[Modules](/docs/modules/)** — Identity, Multitenancy, Auditing, Webhooks.
- **[Guides](/docs/guides/)** — task-oriented walkthroughs.
---
# Prerequisites
> What you need installed locally before running a fullstackhero project.
Source: https://fullstackhero.net/docs/getting-started/prerequisites/
A short checklist. The rest of Getting Started assumes these are in place.
## Required
- **.NET 10 SDK** — from [dotnet.microsoft.com/download](https://dotnet.microsoft.com/download). The repo's `global.json` pins SDK `10.0.100` (rolling forward to the latest feature band).
- **Docker** — used for the Postgres, Valkey, and MinIO containers (Aspire and the integration tests rely on it). Docker Desktop on macOS / Windows, Docker Engine on Linux.
- **Git** — for cloning the repo.
No separate Aspire install is needed: the AppHost project pulls the Aspire SDK (`Aspire.AppHost.Sdk`) from NuGet during restore.
## Optional
- **Node 20+** — only if you'll run the React + Vite frontends in `clients/admin` and `clients/dashboard` (both declare `"node": ">=20"`).
- **PostgreSQL** — install locally only if you'd rather not run Postgres in a container. Otherwise Aspire provisions one for you (the kit's containers run Postgres 18).
- **Valkey** — same. The kit runs Valkey 9 in its containers (a Redis-compatible, BSD-licensed Redis fork); any Redis-protocol server also works.
## Verify
```bash
dotnet --version # 10.x.x
docker info # should print server info, not error
git --version
```
If `dotnet --version` reports 9.x or older, install the .NET 10 SDK before continuing — the kit targets `net10.0` and won't build on earlier SDKs.
## Next
[Quick Start](/docs/getting-started/quick-start/) → clone the repo and run it.
---
# Quick Start
> Clone fullstackhero and run the whole stack locally with one command.
Source: https://fullstackhero.net/docs/getting-started/quick-start/
This page clones the repo as-is (keeping its `FSH.Starter` names). To scaffold a
fully renamed project in one command, install the `fsh` CLI and run
`fsh new MyApp` instead — see [Install](/docs/getting-started/install/) for every
path.
The quickest way to run the kit as-is is to clone the repo and start it with .NET Aspire.
## 1. Clone the repo
```bash
git clone https://github.com/fullstackhero/dotnet-starter-kit.git MyApp
cd MyApp
```
A clone keeps the original `FSH.Starter` project names — it doesn't rename the solution. To get a fully renamed project instead, use `fsh new MyApp` (see [Install](/docs/getting-started/install/)).
## 2. Install frontend dependencies (one time)
Aspire launches both React apps for you, but it doesn't install their packages:
```bash
cd clients/admin && npm install
cd ../dashboard && npm install
cd ../..
```
Skip this if you only want the API.
## 3. Run
```bash
dotnet run --project src/Host/FSH.Starter.AppHost
```
Aspire brings up Postgres (with pgAdmin), Valkey, and MinIO in containers, runs the DbMigrator (migrations + seed) and the demo seeder, then starts the API and both React apps — all wired together with connection strings and OTLP export. Watch everything come up in the Aspire dashboard.
Once it's green:
| What | Where |
|---|---|
| API + Scalar docs | `https://localhost:7030/scalar` |
| Admin app | `http://localhost:5173` |
| Dashboard app | `http://localhost:5174` |
Log in with one of the seeded [demo accounts](/docs/getting-started/demo-accounts/) — the dashboard login page has a persona picker in dev.
## Next
- **[Demo accounts](/docs/getting-started/demo-accounts/)** — the seeded tenants, personas, and what each one demonstrates.
- **[Troubleshooting](/docs/getting-started/troubleshooting/)** — fixes for the common first-run failures (containers, volumes, ports, migrations).
- **[Install](/docs/getting-started/install/)** — every install path (GitHub template, Codespaces, the CLI, and the `dotnet new` template).
- **[Architecture](/docs/architecture/)** — the modular monolith + VSA mental model.
- **[CLI](/docs/cli/)** — the `fsh` commands for scaffolding and managing a project.
---
# Troubleshooting
> Symptom → cause → fix for the failure modes you actually hit on first run and in the daily dev loop.
Source: https://fullstackhero.net/docs/getting-started/troubleshooting/
Every entry below is a failure mode someone actually hit, in symptom → cause → fix
form. Resource and volume names come straight from
`src/Host/FSH.Starter.AppHost/AppHost.cs` — the AppHost derives a per-app prefix from
its assembly name, so for a stock clone the prefix is **`fsh-starter`** and the Docker
volumes are `fsh-starter-postgres-data`, `fsh-starter-redis-data`, and
`fsh-starter-minio-data`. If you renamed the solution, swap the prefix accordingly.
## First run
### React apps die immediately at AppHost launch
**Symptom:** the `fsh-starter-admin` / `fsh-starter-dashboard` resources fail right after start; logs show `'vite' is not recognized` (Windows) or `sh: vite: not found`.
Aspire launches both apps with `AddJavaScriptApp(...).WithNpm()` running their `dev`
script — it does **not** run `npm install` for you. A fresh clone has no
`node_modules` in either app.
```bash
cd clients/admin && npm install
cd ../dashboard && npm install
```
Then relaunch the AppHost. This is a one-time step per clone (and again whenever
`package-lock.json` changes).
### Port conflicts
**Symptom:** a resource fails to bind, or the browser shows a different app on a familiar port.
These are the fixed ports the stack claims. Postgres and Valkey are *not* on this
list — Aspire assigns them dynamic host ports (check the Aspire dashboard), so a
locally installed Postgres on 5432 won't clash.
| Port | What |
|---|---|
| 7030 / 5030 | API https / http (`launchSettings.json`) |
| 5173 | Admin app (Vite) |
| 5174 | Dashboard app (Vite) |
| 5050 | pgAdmin |
| 5540 | RedisInsight |
| 9000 / 9001 | MinIO API / console |
| 17273 / 15036 | Aspire dashboard https / http |
| 4317 | Aspire OTLP ingestion |
Find and kill whatever holds a port:
```bash
# Windows
netstat -ano | findstr :5173
taskkill /PID /F
# macOS / Linux
lsof -i :5173
kill
```
## Containers & Aspire
The infrastructure containers (`postgres`, its pgAdmin sidecar, `redis` (Valkey),
`redis-insight`, `minio`) all use `ContainerLifetime.Persistent` — they survive
AppHost shutdown on purpose. That's what makes restarts fast, and it's also the root
of the next three entries.
### Postgres exits after an Aspire version bump
**Symptom:** the `postgres` container is `Exited (1)`; its log complains about existing data in `/var/lib/postgresql` (an initialized data directory from another major version).
The AppHost calls `AddPostgres("postgres")` without pinning an image tag, so the
Postgres major version tracks the Aspire package default — Aspire 13.4 moved it to
PG 18. Your persistent data volume was initialized by the older major, and Postgres
refuses to start on a data directory it can't read.
Wipe the container and the volume (this deletes all local data — the migrator
recreates and reseeds everything on the next launch):
```bash
# Find the container that mounts the volume
docker ps -a --filter volume=fsh-starter-postgres-data --format "{{.ID}} {{.Names}} {{.Status}}"
docker rm -f
docker volume rm fsh-starter-postgres-data
```
Relaunch the AppHost; the DbMigrator reapplies migrations and reseeds.
### Containers orphaned from a deleted Docker network
**Symptom:** after a Docker Desktop restart or an Aspire upgrade, containers show `Exited (255)` or hang in *Starting*; logs or `docker inspect` mention `network ... not found`; the API and both React apps sit in *Waiting* forever.
Persistent containers outlive the Docker network Aspire created them on. When that
network is gone (Docker restarted, Aspire bumped), the container references a
network ID that no longer exists and can't start. Data volumes are separate objects —
removing the container is safe.
```bash
docker ps -a
docker rm -f # e.g. the redis / redis-insight / postgres / minio container
```
Relaunch the AppHost; it recreates the containers on a fresh network and reattaches
the existing volumes.
### Valkey won't start — RDB written by a newer engine
**Symptom:** the `redis` container (it runs Valkey) exits on start; the log complains it can't read the RDB dump (e.g. `Can't handle RDB format version ...`).
The `fsh-starter-redis-data` volume holds a dump written by a newer engine version
than the one now starting — RDB files are not backward-compatible. This happens when
a newer image wrote the volume and you later run an older one. The volume only holds
cache data, so wiping it is harmless:
```bash
docker rm -f
docker volume rm fsh-starter-redis-data
```
## Database & migrations
### "relation ... does not exist"
**Symptom:** API requests fail with Npgsql `42P01: relation "..." does not exist`.
The database is **not** migrated at API startup — migrations are applied by the
separate DbMigrator. Under the AppHost this is automatic (the API waits for the
migrator to complete), so you mainly hit this when running the API standalone
against a fresh or out-of-date database.
```bash
dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply --seed
```
`apply` migrates, `--seed` also seeds the root tenant + admin. Use
`-- list-pending` to see what would run without applying anything.
### seed-demo refuses to run
**Symptom:** `[demo-seed] REFUSING to run — DOTNET_ENVIRONMENT is 'Production'. seed-demo is dev-only by design.`
The demo seeder (acme/globex tenants + demo users) hard-fails outside Development.
The DbMigrator is a console host, which reads `DOTNET_ENVIRONMENT` — setting
`ASPNETCORE_ENVIRONMENT` alone is ignored.
```bash
# PowerShell
$env:DOTNET_ENVIRONMENT = "Development"; dotnet run --project src/Host/FSH.Starter.DbMigrator -- seed-demo
# bash
DOTNET_ENVIRONMENT=Development dotnet run --project src/Host/FSH.Starter.DbMigrator -- seed-demo
```
## API & configuration
### API refuses to boot in Production — JWT signing key
**Symptom:** startup throws `OptionsValidationException` with `No Key defined in JwtOptions config`, `SigningKey must be at least 32 characters long.`, or `SigningKey looks like a sample placeholder ('replace-with-…')`.
`JwtOptions` is validated with `ValidateOnStart()`: the signing key must be present,
at least 32 characters, and must not contain the `replace-with` sample placeholder —
otherwise every token the API issued would be forgeable by anyone with repo access.
`appsettings.json` and `appsettings.Production.json` ship with an **empty**
`SigningKey` on purpose; only `appsettings.Development.json` has a dev-only default.
```bash
# Set a real secret (>= 32 chars) via environment variable
JwtOptions__SigningKey=""
```
Locally you can use user-secrets instead; in production use env vars or your secret
store.
`HangfireOptions` (`UserName` min 3 chars, `Password` min 12) and `CorsOptions`
(`AllowedOrigins` required when `AllowAll` is false) are validated at startup the
same way — and both are intentionally blank in `appsettings.Production.json`. A
Production boot needs all three configured.
### SignalR fails in dev while REST works
**Symptom:** REST calls succeed, but the SignalR `/negotiate` request dies with a CORS error in the browser console.
SignalR's negotiate always runs **credentialed**, and the CORS spec forbids the `*`
wildcard with credentials — so a plain allow-anything policy silently breaks SignalR
while ordinary fetches keep working. The kit's policy handles this: with
`CorsOptions:AllowAll = true` (the Development default) it echoes the request origin
via `SetIsOriginAllowed(_ => true)` + `AllowCredentials()`. If you hit this, your
environment isn't using the Development config — either set `AllowAll` to `true` for
dev, or list your frontend origin explicitly:
```bash
CorsOptions__AllowAll=true
# or, for a locked-down config:
CorsOptions__AllowedOrigins__0=https://app.example.com
```
Never replace the policy with `AllowAnyOrigin()` — it cannot be combined with
credentials and reintroduces exactly this failure.
### Hangfire dashboard prompts or returns 401
**Symptom:** `/jobs` shows a Basic-auth prompt (`Hangfire Dashboard` realm) and rejects your credentials with 401.
The dashboard is guarded by a Basic-authentication filter whose credentials come
from `HangfireOptions` (`UserName` / `Password`, dashboard route defaults to
`/jobs`). Under the AppHost the dev values are `admin` / `Password123!`. There is no
safe default — in any other environment you must set them yourself, and startup
validation rejects a missing username or a password shorter than 12 characters:
```bash
HangfireOptions__UserName=admin
HangfireOptions__Password=
```
## Tests
### Integration tests fail instantly — Docker not running
**Symptom:** every test in `Integration.Tests` fails fast with `DockerUnavailableException`.
The integration suite runs the real API over Testcontainers (PostgreSQL + MinIO
spun up per test run), so a running Docker daemon is a hard requirement. This is
environmental, not a regression.
```bash
# Start Docker Desktop / the docker daemon, then:
dotnet test src/FSH.Starter.slnx
```
If you can't run Docker, run the unit-test projects individually (everything except
`Integration.Tests` / `Integration.Middleware.Tests`) — they have no container
dependency.
---
# Overview
> Task-oriented walkthroughs — add a feature, add a module, add a dashboard page, stay current with upstream, run operator impersonation.
Source: https://fullstackhero.net/docs/guides/
Step-by-step recipes for the most common things teams do with the kit.
---
# Add a page to the tenant dashboard
> End-to-end recipe for adding a new page to the tenant dashboard app — API module, page component, lazy route, sidebar nav entry, permission gating, and a route-mocked Playwright test.
Source: https://fullstackhero.net/docs/guides/add-a-dashboard-page/
This recipe walks a new screen into `clients/dashboard` end-to-end: the API module, the page component, the lazy route, the sidebar entry, permission gating, and the Playwright test. The running example is a **Projects** list-plus-create page; swap in your own resource. For the surrounding context — app shell, providers, design tokens — see the [frontend architecture](/docs/frontend/architecture/) and [dashboard app](/docs/frontend/dashboard/) pages.
The dashboard (tenant-facing) and admin (operator-facing) apps share a stack but differ on purpose: the dashboard hand-rolls forms (no react-hook-form/zod), uses camelCase query params, and has **no per-route permission guard**. If you're adding an admin page instead, the differences are summarized [at the end](#how-the-admin-app-differs).
## Step 1 — API module (`src/api/projects.ts`)
Types are **hand-written** — there is no codegen step. One file per feature in `src/api/`, exporting DTO types and thin async functions over `apiFetch` (from `@/lib/api-client`, which handles the bearer token, the `tenant` header, single-flight 401 refresh, and throws `ApiRequestError` on problem-details responses).
Condensed from the real `src/api/tickets.ts`:
```ts
import { apiFetch } from "@/lib/api-client";
import type { PagedResponse } from "@/api/catalog"; // or re-declare inline
export type ProjectDto = {
id: string;
name: string;
description?: string | null;
createdAtUtc: string;
};
export type CreateProjectInput = { name: string; description?: string | null };
const BASE = "/api/v1";
export function searchProjects(params: {
search?: string;
pageNumber?: number;
pageSize?: number;
} = {}): Promise> {
const q = new URLSearchParams();
if (params.search) q.set("search", params.search);
q.set("pageNumber", String(params.pageNumber ?? 1));
q.set("pageSize", String(params.pageSize ?? 20));
return apiFetch>(`${BASE}/projects?${q.toString()}`);
}
export function createProject(input: CreateProjectInput): Promise {
return apiFetch(`${BASE}/projects`, {
method: "POST",
body: JSON.stringify({ name: input.name, description: input.description ?? null }),
});
}
```
Dashboard conventions to match (they differ from admin): **camelCase** query params (`pageNumber`, `search`), create mutations usually return `Promise` (the new id), and `PagedResponse` is either re-declared inline or imported from an existing api module — there's no central `api-types` file here.
## Step 2 — Page component (`src/pages/projects/projects.tsx`)
Pages are **named exports** (no default exports anywhere) and keep `useQuery`/`useMutation` inline. Query keys are hierarchical literal arrays with the params object last; paginated lists use `placeholderData: keepPreviousData`.
Condensed from `src/pages/tickets/tickets.tsx`:
```tsx
export function ProjectsPage() {
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [pageNumber, setPageNumber] = useState(1);
useEffect(() => {
const id = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => window.clearTimeout(id);
}, [search]);
useEffect(() => setPageNumber(1), [debouncedSearch]);
const query = useQuery({
queryKey: ["projects", "list", { search: debouncedSearch, pageNumber }],
queryFn: () => searchProjects({ search: debouncedSearch || undefined, pageNumber }),
placeholderData: keepPreviousData,
});
// render with EntityPageHeader + the components/list family + components/ui primitives
}
```
UI building blocks:
- **Primitives** live in `src/components/ui/` — cva-based, shadcn-style (`button`, `card`, `dialog`, `input`, `badge`, `dropdown-menu`, …). List/page furniture (the `Entity*` family like `EntityPageHeader`) lives in `src/components/list/`.
- **Tailwind v4 is CSS-first** — no `tailwind.config`; tokens are CSS variables in `src/styles/globals.css`. Don't hard-code colors, and keep dashboard neutrals at chroma 0.
- Long lists should use `@tanstack/react-virtual`.
### Forms: hand-rolled, not react-hook-form
The dashboard does **not** depend on react-hook-form or zod — that's an admin-only stack. Use controlled inputs with local `useState` and a plain `onSubmit`. List + create typically live in **one file**, with the editor in a `