LICENSE Documentation

Official TypeScript developer resources and documentation for fullstackhero/LICENSE. Verified technical context for AI.

0 stars TypeScript TypeScript 1 file ยท ~11,600 tokens
RAW Doc

1. Project Overview & Quickstart (fullstackhero/LICENSE)

LICENSE

Open-source repository fullstackhero/LICENSE

Repository Details

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

bash
cd docs
npm install
npm run dev

The 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

bash
npm run build      # astro build && pagefind --site dist
npm run preview    # serves dist/ on a local port

Deploy

Cloudflare Pages, configured by wrangler.toml. Production branch: main. Build command for the Pages dashboard:

text
npm ci && npm run build

Output 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:

yaml
---
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.

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 from
codewithmukesh/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.ogImage per-page frontmatter (src/content.config.ts).
  • A site fallback ogImage: '/logo-fullstackhero.png' (src/data/site.ts).
  • BaseLayout.astro absolutizes image ?? siteConfig.ogImage into og:image / twitter:image.
  • src/pages/docs/[...slug].astro reads seo.ogImage and passes it down.
  • MarketingLayout.astro already forwards an image prop to BaseLayout.

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.ogImage still 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 copies public/).

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

  1. 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 setContent needs 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.
  2. scripts/og/templates/landing.html, scripts/og/templates/404.html - bespoke site cards
    (self-contained HTML referencing the shared styles), rendered to public/og/<name>.jpg.

  3. 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-driven doc template. Directory starts empty.

  4. scripts/og/generate.mjs - orchestrator (adapted from the blog).

    • Globs src/content/docs/**/*.mdx, parses frontmatter with gray-matter.
    • Determines the slug and variant per page (see slug mapping).
    • Resolves section label from src/content/docs/_sections.ts for the eyebrow.
    • For each page: if a bespoke override HTML exists, render it; else build HTML via
      renderDocsTemplate and render via page.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.ready before screenshotting.

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 (variant home)
  • architecture/index.mdx โ†’ public/og/docs/architecture.jpg (variant overview)
  • architecture/vertical-slice.mdx โ†’ public/og/docs/architecture/vertical-slice.jpg (variant doc)

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

text
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 template

npm run og maps to the no-arg form.

Wiring

  • src/pages/docs/[...slug].astro - change const image = seo.ogImage; to
    const image = seo.ogImage ?? /og/docs/${slugForCrumb || 'index'}.jpg;
  • src/pages/index.astro - <MarketingLayout image="/og/landing.jpg">
  • src/pages/404.astro - add image="/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 build copies public/ into dist/.

Verification

  1. Render one page of each variant (home, overview, doc, plus landing/404); inspect inline.
  2. Iterate on the templates until the cards look right.
  3. Render all pages.
  4. npm run check - type + content-schema validation passes.
  5. npm run build - confirms images are emitted into dist/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:

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

text
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 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

bash
npm install @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<T>.
  • 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/ 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).
Everything here is plain Markdown keyed off the open [AGENTS.md](https://agents.md) convention. It works with Claude Code, Gemini CLI, Cursor, Codex, and anything else that reads `AGENTS.md` - no vendor lock-in.

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).
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](/docs/ai-development/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.


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:

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<T> / IQuery<T> / 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<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. 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:

text
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.
  • 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.


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.

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

<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:

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 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, 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.