1. Project Overview & Quickstart (fullstackhero/LICENSE)
LICENSE
Open-source repository fullstackhero/LICENSE
Repository Details
- Repository: fullstackhero/LICENSE
- Primary Language: Code
Note: High-volume repository documentation is actively indexed and synchronized by YakaAI.
2. Official Technical Reference & Guides (fullstackhero/docs)
File: README.md
FullStackHero Docs
Documentation site for the FullStackHero .NET starter kit. Built with Astro 6 and Tailwind 4.
Local development
cd docs
npm install
npm run devThe site runs on http://localhost:4321. Edit MDX files under src/content/docs/ and the dev server hot-reloads. Note that pagefind search is built only by npm run build, so the search modal will report "index not available" during dev.
Build
npm run build # astro build && pagefind --site dist
npm run preview # serves dist/ on a local portDeploy
Cloudflare Pages, configured by wrangler.toml. Production branch: main. Build command for the Pages dashboard:
npm ci && npm run buildOutput directory: dist.
Content authoring
Add a new doc page by creating an MDX file under src/content/docs/<section>/. Frontmatter shape is defined by src/content.config.ts:
---
title: Page title
sidebar:
label: Optional override of title in sidebar
order: 10 # lower = higher in section
hidden: false # exclude from sidebar but still routable
pageType: guide # guide | reference | concept | recipe
---The sidebar tree is derived from file structure. Section labels and order live in src/content/docs/_sections.ts.
Design system
Tokens, prose styles, and code-block themes are forked verbatim from codewithmukesh/blog (see provenance comments in src/styles/*.css). Brand primary is #15803d (green-700); the user-facing brand green #16a34a lives in --primary-soft so it surfaces in accent text, tinted backgrounds, and the gradient mid-stop.
Search
Pagefind. Index is generated as a post-build step over the dist/ HTML. Only /docs/* pages are indexed (scoped via data-pagefind-body on the docs <article>). Trigger via Ctrl/Cmd+K.
File: docs/specs/2026-05-24-og-image-generator-design.md
OG Image Generator - Design
Date: 2026-05-24
Status: Approved, ready for implementation plan
Goal
Generate per-page Open Graph / Twitter share images (1200ร675) for the FullStackHero
documentation site, replacing the current fallback to the 512ยฒ square logo
(/logo-fullstackhero.png). Adapt the Playwright-based renderer fromcodewithmukesh/blog (scripts/og/generate.mjs), but reshape it from the blog's
one-bespoke-HTML-per-page model into a data-driven generator that produces a card
for every one of the ~86 docs pages from frontmatter, plus the marketing landing and 404.
Why the blog's approach can't be copied verbatim
The blog hand-crafts one HTML template per article. With 86 docs pages that doesn't scale.
This repo, however, already has the SEO plumbing the generator can plug into:
seo.ogImageper-page frontmatter (src/content.config.ts).- A site fallback
ogImage: '/logo-fullstackhero.png'(src/data/site.ts). BaseLayout.astroabsolutizesimage ?? siteConfig.ogImageintoog:image/twitter:image.src/pages/docs/[...slug].astroreadsseo.ogImageand passes it down.MarketingLayout.astroalready forwards animageprop toBaseLayout.
The site also shares the blog's exact fonts (Outfit / Figtree / JetBrains Mono) and dark
background (#0d0e11), so the blog's CSS ports over directly - only the accent color changes
from purple to FSH green (--primary: #15803d, --primary-soft: #16a34a).
Decisions (from brainstorming)
- Hybrid template model: a data-driven default template per page, with an optional bespoke
per-page HTML override as an escape hatch for hero pages. - FSH green, no portrait: adapt the blog's v2 layout; right column shows the FSH triangle
mark instead of a face portrait. - Auto-derive by slug + override: images are wired by slug convention;
seo.ogImagestill wins. - Four template variants (see below).
- pageType drives accent + badge only within the standard doc template (not separate layouts).
- Commit the generated JPEGs under
public/og/so deploys ship them without running
Playwright in CI (the build just copiespublic/).
Template variants
The generator selects a variant automatically:
| Variant | Applies to | Look |
|---|---|---|
home |
/docs/ (root index.mdx) |
Hero: large "FullStackHero" wordmark + tagline + prominent FSH logo |
overview |
every <section>/index.mdx |
Section name as headline + "Section Overview" eyebrow + "N pages" badge + description |
doc |
leaf docs pages (~60) | Section eyebrow + pageType badge & accent + title + description + FSH mark |
| site templates | / (landing) and 404 |
Bespoke HTML files (blog-style): marketing card / centered 404 card |
pageType accent + badge (within the doc variant):
| pageType | Accent | Badge |
|---|---|---|
guide |
green (primary) | GUIDE |
reference |
blue | REFERENCE |
concept |
violet | CONCEPT |
recipe |
amber | RECIPE |
Architecture
Components
scripts/og/template.mjs- pure rendering module.- Exports
renderDocsTemplate({ variant, eyebrow, title, description, badge, accent })
returning a full HTML document string. - Holds the shared CSS (FSH-green port of the blog's
_v2-shared.css). - Inlines the FSH logo as a base64 data URI so Playwright
setContentneeds no file paths. - Title auto-sizes by length tier; description truncates to ~150 chars.
- No I/O, no Playwright - independently testable by inspecting returned HTML.
- Exports
scripts/og/templates/landing.html,scripts/og/templates/404.html- bespoke site cards
(self-contained HTML referencing the shared styles), rendered topublic/og/<name>.jpg.scripts/og/templates/docs/<slug>.html- optional per-page override. If a file exists for
a slug, the generator renders it instead of the data-drivendoctemplate. Directory starts empty.scripts/og/generate.mjs- orchestrator (adapted from the blog).- Globs
src/content/docs/**/*.mdx, parses frontmatter withgray-matter. - Determines the slug and variant per page (see slug mapping).
- Resolves section label from
src/content/docs/_sections.tsfor the eyebrow. - For each page: if a bespoke override HTML exists, render it; else build HTML via
renderDocsTemplateand render viapage.setContent. - Also renders the named site templates (
landing,404). - Screenshots at 1200ร675, deviceScaleFactor 2, JPEG quality 88 (identical to the blog).
- Waits for
networkidle+document.fonts.readybefore screenshotting.
- Globs
Slug โ output mapping (mirrors `[...slug].astro`)
The docs slug is entry.id minus .mdx, with /index stripped and root index โ empty.
Output path is public/og/docs/<slug>.jpg, with the root using index:
- root
index.mdxโpublic/og/docs/index.jpg(varianthome) architecture/index.mdxโpublic/og/docs/architecture.jpg(variantoverview)architecture/vertical-slice.mdxโpublic/og/docs/architecture/vertical-slice.jpg(variantdoc)
Variant selection:
- normalized id
=== 'index'โhome - normalized id ends with
/indexโoverview - otherwise โ
doc
Eyebrow text: section label from _sections.ts (e.g. "Architecture"); root/sectionless โ "FullStackHero".
CLI
node scripts/og/generate.mjs # render everything
node scripts/og/generate.mjs docs/architecture/vertical-slice # one docs page (by slug)
node scripts/og/generate.mjs landing # one named site templatenpm run og maps to the no-arg form.
Wiring
src/pages/docs/[...slug].astro- changeconst image = seo.ogImage;toconst image = seo.ogImage ??/og/docs/${slugForCrumb || 'index'}.jpg;src/pages/index.astro-<MarketingLayout image="/og/landing.jpg">src/pages/404.astro- addimage="/og/404.jpg"to its<MarketingLayout>.
BaseLayout already turns these relative paths into absolute og:image / twitter:image URLs.seo.ogImage frontmatter continues to override per page.
package.json
- devDependencies:
playwright,gray-matter. - script:
"og": "node scripts/og/generate.mjs". - Chromium provisioned once via
npx playwright install chromium.
Output & version control
- Generated JPEGs live under
public/og/(public/og/docs/*.jpg,public/og/landing.jpg,public/og/404.jpg) and are committed.npm run buildcopiespublic/intodist/.
Verification
- Render one page of each variant (
home,overview,doc, pluslanding/404); inspect inline. - Iterate on the templates until the cards look right.
- Render all pages.
npm run check- type + content-schema validation passes.npm run build- confirms images are emitted intodist/og/**and pages reference the right paths.
Out of scope
- Running Playwright during CI/build (images are committed instead).
- Per-page bespoke overrides beyond the empty
templates/docs/escape hatch. - OG images for any future non-docs routes not listed above.
File: src/content/docs/ai-development/agents-md.mdx
title: AGENTS.md & the .agents Folder lastUpdated: 2026-06-11 sidebar: label: AGENTS.md & .agents/ order: 1 pageType: reference
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:
# Claude Code
The canonical project guide is **`AGENTS.md`** (tool-neutral). It is imported below; edit conventions there, not here.
@AGENTS.mdSo every tool - and every contributor - reads the same guide, and there's exactly one place to update.
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}.mdRules - 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 viaBaseDbContext; 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 that turn these conventions
into repeatable actions.
File: src/content/docs/ai-development/claude-code.mdx
title: Developing with Claude Code lastUpdated: 2026-05-24 sidebar: label: Developing with Claude Code order: 3 pageType: guide seo: title: 'Developing fullstackhero with Claude Code' keywords: 'Claude Code, .NET, AGENTS.md, AI pair programming, fullstackhero, agent skills, code review workflow'
Claude Code 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
npm install @anthropic-ai/claude-code # see the official docs for native installers
cd dotnet-starter-kit
claudeOn 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.
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:
"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 runsdetect_antipatterns and get_diagnostics for machine-found issues.
4. The build/test loop
dotnet build src/FSH.Starter.slnx # 0 warnings - the repo runs TreatWarningsAsErrors
dotnet test src/FSH.Starter.slnx # unit + architecture + integrationTo 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:
dotnet run --project src/Host/FSH.Starter.AppHost5. 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 byArchitecture.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 viaIGlobalEntity. - Every command + paginated query needs a validator; handlers are
public sealed, returnValueTask<T>. - Structured logging only - no string interpolation in log messages.
- Don't modify
src/BuildingBlockswithout 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/ page explains the structure;
Skills & Workflows shows how to extend the set.
File: src/content/docs/ai-development/index.mdx
title: Overview lastUpdated: 2026-05-24 sidebar: label: Overview order: 0 pageType: concept seo: title: 'AI-Driven Development with fullstackhero' keywords: 'AGENTS.md, .agents folder, Claude Code, AI coding agent, agent rules skills workflows, .NET starter kit AI'
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).
File: src/content/docs/ai-development/skills-and-workflows.mdx
title: Skills & Workflows lastUpdated: 2026-05-24 sidebar: label: Skills & Workflows order: 2 pageType: reference
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<T> 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). |
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.
File: src/content/docs/architecture/dependency-injection.mdx
title: Dependency injection & module loading lastUpdated: 2026-06-11 sidebar: label: Dependency Injection order: 5 pageType: concept seo: title: 'Dependency injection in .NET 10 - Module loader + Mediator' keywords: '.net dependency injection patterns, IModule asp.net core, mediator source generator dotnet, fluentvalidation auto register, addheroplatform composition root'
fullstackhero's composition root is src/Host/FSH.Starter.Api/Program.cs, and it's short. Condensed:
// 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
builder.Services.AddMediator(o => ...)- registers Mediator 3's source-generated dispatch.o.Assembliesis a list of marker types: the generator scans the assembly each type lives in forICommand<T>/IQuery<T>/ handler implementations. Every module contributes two markers - one type from its.Contractsassembly and one from its runtime assembly. Handlers are registered withServiceLifetime.Scoped.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.builder.AddModules(moduleAssemblies)- runs theModuleLoader. It registers every FluentValidation validator in the supplied assemblies, discovers the assembly-level[FshModule]attributes, sorts modules by order, and invokes each one'sConfigureServices(IHostApplicationBuilder).app.UseHeroPlatform(p => ...)- wires the middleware pipeline in the right order, invoking each module'sConfigureMiddleware(right after authentication) andMapEndpoints(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):
// BuildingBlocks/Web/Modules/IModule.cs
public interface IModule
{
void ConfigureServices(IHostApplicationBuilder builder);
void MapEndpoints(IEndpointRouteBuilder endpoints);
void ConfigureMiddleware(IApplicationBuilder app) { } // optional - default no-op
}// Modules.Identity/AssemblyInfo.cs
[assembly: FshModule(typeof(FSH.Modules.Identity.IdentityModule), 100)]// 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<TCommand> 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<MyCommand> 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.
FshJobActivatorcreates anIServiceScopeper 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);
OutboxDispatcherHostedServicepicks 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:
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.
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 doneThree 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. - CORS before HTTPS redirect. Preflight
OPTIONSrequests can't follow redirects per the Fetch spec. - Per-module middleware after authentication.
UseModuleMiddlewaresruns each module'sConfigureMiddlewareright afterUseAuthentication, 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 - the composition + middleware code.
- Vertical Slice - what a handler looks like.
- Modular monolith - the module ordering rules.
File: src/content/docs/architecture/index.mdx
title: Overview lastUpdated: 2026-05-19 sidebar: order: 1 pageType: concept seo: title: 'Architecture - modular monolith + vertical slice in .NET 10'
How fullstackhero is put together - the modular monolith, vertical slice architecture, and how modules talk to each other through contracts.
File: src/content/docs/architecture/modular-monolith.mdx
title: Modular monolith lastUpdated: 2026-06-11 sidebar: label: Modular monolith order: 2 pageType: concept seo: title: 'Modular monolith in .NET 10 - module boundaries enforced by' keywords: 'modular monolith .net 10, .net module boundaries, netarchtest enforced architecture, modular monolith to microservices migration'
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.
The shape
<FolderTree
root="src/"
caption="One repo ยท one process"
tree={[
{
name: 'BuildingBlocks/',
note: 'Shared infrastructure - 11 libraries',
chips: [
'Core',
'Persistence',
'Web',
'Shared',
'Caching',
'Eventing',
'Eventing.Abstractions',
'Jobs',
'Mailing',
'Storage',
'Quota',
],
},
{
name: 'Modules/',
note: 'Ten bounded contexts',
children: [
{
name: 'Identity/',
children: [
{ name: 'Modules.Identity/', note: 'Runtime - endpoints, handlers, domain' },
{
name: 'Modules.Identity.Contracts/',
note: 'Public surface - commands, queries, DTOs, events',
highlight: true,
},
],
},
{ name: 'Multitenancy/', badge: '+ Contracts' },
{ name: 'Auditing/', badge: '+ Contracts' },
{ name: 'Files/', badge: '+ Contracts' },
{ name: 'Chat/', badge: '+ Contracts' },
{ name: 'Notifications/', badge: '+ Contracts' },
{ name: 'Webhooks/', badge: '+ Contracts' },
{ name: 'Billing/', badge: '+ Contracts' },
{ name: 'Catalog/', badge: '+ Contracts' },
{ name: 'Tickets/', badge: '+ Contracts' },
],
},
{
name: 'Host/',
note: 'Composition root + orchestrator',
children: [
{ name: 'FSH.Starter.Api/', note: 'The API process' },
{ name: 'FSH.Starter.AppHost/', note: '.NET Aspire orchestrator' },
{ name: 'FSH.Starter.DbMigrator/', note: 'Migrations + demo seeder CLI' },
{ name: 'FSH.Starter.Migrations.PostgreSQL/' },
],
},
{ name: 'Tools/CLI/', note: '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:
// 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 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<TEvent> receives a copy, with inbox-backed idempotency. Examples in the kit:
- Chat โ Notifications:
MentionedInChannelIntegrationEventโ inbox row + SignalR push. - Any module โ Webhooks: open-generic
WebhookFanoutHandler<TEvent>fans every event to subscribed tenants. - Identity โ outbox:
UserRegisteredIntegrationEvent,TokenGeneratedIntegrationEventfor 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 (order800) because Notifications registers handlers for Chat'sMentionedInChannelIntegrationEvent. If Chat loaded first, the handler wouldn't be wired when the first mention event fires. ICurrentUseravailability. Identity (order100) loads first so every later module sees the JWT pipeline andICurrentUserregistered.
| 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:
- The module's runtime moves into its own service.
- Its
*.Contractsassembly is shared (either as a NuGet package or duplicated). - The cross-module communication switches from in-process Mediator (for service calls) and
InMemoryEventBus(for events) to HTTP / RabbitMQ. - 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 - what each module looks like internally.
- Modules overview - the ten that ship in v10.
- Eventing.Abstractions - the contract for cross-module talk.
- Web building block - the module loader.