A .NET 10 and React 19 reference application built on Clean Architecture, where the architectural rules are enforced by tooling rather than by convention.
The domain is deliberately small — users place orders against a catalogue — so that the interesting part is the scaffolding around it: a hook that blocks a layering violation at edit time, a transactional outbox, RabbitMQ for every asynchronous hop behind a durable Wolverine outbox, background jobs in a worker host of their own, outbound integrations over mTLS (OCES3 certificates) and OAuth 2.0 client credentials, an API contract generated from the application and committed to the repository, and a local Kubernetes cluster that runs the whole stack at two replicas to rehearse the failures that only appear above one.
- Background
- Architecture
- Tech stack
- Repository layout
- Install
- Usage
- API
- Frontend application
- Generated code
- Testing
- Continuous integration
- Conventions
- Architecture decision records
- Troubleshooting
- Contributing
- License
Most Clean Architecture samples describe their dependency rule in prose and then rely on review to uphold it. This repository takes the position that a rule nobody can enforce is a rule that erodes, so the dependency rule is a pre-edit hook that refuses the write, the analyzers run as errors rather than warnings, and the generated artifacts that couple the two stacks together are committed and re-checked by CI.
The design rationale is recorded in docs/adr/ and in the specs under
docs/superpowers/specs/. Working notes for contributors and agents
live in CLAUDE.md and in a per-layer CLAUDE.md inside each project.
Five projects: three inner layers, and two composition roots over them. Dependencies point inward only.
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ Api │ │ Worker │
│ Controllers, DTOs, hub, │ │ Job handlers and the │
│ outbox pumps, exceptions │ │ Quartz scheduler │
└──────┬───────────────┬──────┘ └──────┬───────────────┬──────┘
│ │ DI only │ DI only │
│ ▼ ▼ │
│ ┌──────────────────────────────┐ │
│ │ Infrastructure │ │
│ │ EF Core, repositories, │ │
│ │ outbox, cache, Wolverine │ │
│ └──────────────┬───────────────┘ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────────────┐
│ Application │
│ Use cases, ports, Result<T>, validators │
└───────────────────────────────┬───────────────────────────────┘
▼
┌───────────────────────────────────────────────────────────────┐
│ Domain │
│ Entities, value objects, domain events │
└───────────────────────────────────────────────────────────────┘
| From ↓ / To → | Domain | Application | Infrastructure | Api | Worker |
|---|---|---|---|---|---|
| Domain | — | ✗ | ✗ | ✗ | ✗ |
| Application | ✓ | — | ✗ | ✗ | ✗ |
| Infrastructure | ✓ | ✓ | — | ✗ | ✗ |
| Api | ✓ | ✓ | ✓ DI only | — | ✗ |
| Worker | ✓ | ✓ | ✓ DI only | ✗ | — |
Api and Worker are siblings, not layers, and neither may reference the other (ADR 0016).
Worker → Api is blocked for a concrete reason beyond tidiness: it is exactly what would let
the two share one Wolverine generated-code tree, and keeping them separate is what makes each
host's codegen write independently correct.
The rule is enforced twice. .claude/hooks/dependency-rule.ps1 blocks the edit, and an
ArchitectureTests class in every test project fails CI on any commit, Claude's or not:
- The inner layers' tests check assembly references by reflection and reject every
AiFramework.*reference the table does not allow. - The Api and Worker tests enforce "DI only" with ArchUnitNET, which reads the compiled IL:
outside a short composition allowlist (
Program, each host's observability registration, and the generated Wolverine adapters), no type may depend on an Infrastructure type — method bodies, lambdas and async methods included. That is what tells a controller reaching into a repository apart from aservices.AddScoped<>()line.
Domain additionally may not reference EF Core, ASP.NET Core, the DI abstractions,
System.Data, or System.ComponentModel.DataAnnotations.
Three caveats about the hook, all covered by the tests in CI:
The hooks are PowerShell, and they are invoked as
powershell.exe. On Linux or macOS without PowerShell installed, none of them run — the dependency rule, the secrets guard, and the migration guard are all silently inert at edit time. The architecture tests run everywhere, so a violation still failsdotnet test.
The hook's banned-namespace table lists
Workeronly underApi, soDomain → Worker,Application → Worker, andInfrastructure → Workerpass it at edit time. The inner layers' tests reject them.
The hook reads
usingdirectives in.csfiles only, so a fully-qualified inline reference passes it. The tests read what the compiler emitted, so they catch it — but not a<ProjectReference>that no code uses yet, which nothing catches until the first type is named across it.
Five mechanisms are worth knowing about before reading the code:
- In-process messaging without MediatR.
ICommandHandler<TCommand, TResponse>andIQueryHandler<TQuery, TResponse>are dispatched throughICommandDispatcherandIQueryDispatcher, with cross-cutting concerns applied as pipeline behaviors. Expected failures return a failedResult<T>rather than throwing. See ADR 0003. - A transactional outbox. Domain events raised by an aggregate are captured by an EF Core interceptor and written in the same transaction as the aggregate itself, then pumped out by a background service — in the API only, because the notifiers it runs push over SignalR, which lives there (ADR 0028). A durable Wolverine outbox on PostgreSQL sits behind it, and publishes integration events for other systems to RabbitMQ. See ADR 0005 and ADR 0026.
- Opt-in query caching, scoped to the caller. A query implements
ICacheable, a command implementsIInvalidatesCache, and the behavior composes the cache key from the query type and the current user's id. Nothing on the authentication path is cached. See ADR 0009. - Background jobs in a worker host of their own. A job is a message with a lane
(
Light/Heavy, one RabbitMQ quorum queue each), enqueued throughIJobScheduler. The API publishes but listens to nothing; the worker is the only host with listeners, which is asserted from the runtime's own endpoint list rather than intended. Scheduled jobs use Quartz as the clock and fire on exactly one worker. See ADR 0016 and ADR 0017. - Outbound integrations with external systems. Each system is configured by name under
ExternalSystems:Systems:<Name>, with secrets as mounted file paths, never values. One registration,AddClient<TApi>(name), builds a Refit client with a fixed handler chain: outbound traffic counting, the standard resilience handler with this system's own options, per-attempt counting, a zero-delay resend on a 401 with a fresh token, the OAuth 2.0 client-credentials token handler (Keycloak; a client secret orprivate_key_jwt), and a primary handler that presents the system's OCES3 certificate with its full chain. Each system gets probe, certificate-expiry and token health checks, kept out of/health/readyso that a partner's outage never takes a pod out of rotation. See ADR 0031 and theexternal-systemsskill.
| Runtime | .NET 10 (net10.0), C# 14, SDK 10.0.400 |
| Web | ASP.NET Core, controller-based |
| Persistence | PostgreSQL 17 via EF Core 10 and Npgsql |
| Messaging | Hand-rolled dispatchers; WolverineFx 6 for the durable event path, jobs and integration events; RabbitMQ 4 as the broker |
| Jobs | Worker host over RabbitMQ quorum queues; Quartz.NET 4 as the scheduler clock |
| Caching | HybridCache (L1 only today) |
| Realtime | SignalR, opt-in, with a Redis backplane above one replica |
| Validation | FluentValidation |
| Auth | Cookie session with a rotating security stamp; Data Protection key ring in PostgreSQL; capability-named policies over a Member/Admin role |
| Resilience | Microsoft.Extensions.Http.Resilience outbound; EnableRetryOnFailure on Npgsql |
| Integrations | Refit 16 (source-generated) typed clients; Duende.AccessTokenManagement 4 for OAuth 2.0 client credentials against Keycloak; OCES3 client certificates over mTLS |
| Documents | PDFsharp + MigraDoc 6 for the order-export PDF, rendered in the worker |
| Observability | Microsoft.Extensions.Logging + OpenTelemetry logs, traces and metrics over OTLP; Seq locally; OpenSearch, Prometheus and Grafana in-cluster |
| API docs | Microsoft.AspNetCore.OpenApi rendered by Scalar |
| Analysis | .NET analyzers, SonarAnalyzer, Meziantou.Analyzer, AsyncFixer — all as errors |
| Framework | React 19 with TypeScript 6 |
| Build | Vite 8 |
| Routing | React Router 7 |
| Server state | TanStack Query 5 |
| API types | Generated from the committed OpenAPI document |
| PDF viewing | react-pdf (pdf.js), lazy-loaded only on the exports page |
| Styling | Plain CSS with design tokens |
| Lint/format | ESLint (--max-warnings 0) and Prettier |
| Backend tests | xUnit, FluentAssertions, NSubstitute, Testcontainers (PostgreSQL, RabbitMQ, Keycloak), ArchUnitNET, an in-process mTLS partner simulator |
| Frontend tests | Vitest, React Testing Library, MSW |
| End-to-end | Playwright against a built preview bundle |
| Containers | Docker Compose for development, chiselled images for deployment |
| Orchestration | kind with Kustomize overlays and ingress-nginx |
| CI | GitHub Actions, Debug and Release; CodeQL default setup |
Exact pins live in CLAUDE.md, Directory.Build.props, the .csproj files, and
frontend/package.json. CI pins the SDK and Node versions in .github/workflows/ci.yml.
.
├── src/
│ ├── Domain/ Entities, value objects, domain events, domain exceptions
│ ├── Application/ Use cases, ports, Result<T>, validators
│ ├── Infrastructure/ EF Core, repositories, outbox, caching, Wolverine, jobs, security, exports,
│ │ and ExternalSystems/ — the outbound integration plumbing
│ ├── Api/ Controllers, DTOs, the SignalR hub, the outbox pumps, exception handling, composition root
│ │ └── Internal/Generated/ Wolverine adapters for the event path — committed
│ └── Worker/ The job host: handlers, Quartz scheduling, the inbound shipments listener
│ └── Internal/Generated/ Wolverine adapters for the jobs — committed, and its own
├── tests/
│ ├── Domain.Tests/
│ ├── Application.Tests/
│ ├── Infrastructure.Tests/ Testcontainers-backed PostgreSQL fixtures
│ ├── Api.IntegrationTests/ WebApplicationFactory over the real pipeline
│ ├── Worker.IntegrationTests/ Job delivery, scheduling, and the worker's codegen
│ └── PartnerSimulator/ Test-only: a throwaway PKI and an in-process mTLS partner
├── frontend/ Vite + React workspace, Vitest specs, Playwright e2e
├── k8s/
│ ├── base/ api, web, worker, postgres, rabbitmq, redis, ingress, autoscaling
│ ├── components/ observability — OTel Collector, OpenSearch, Prometheus, Grafana; opt-in
│ └── overlays/ local, and local-observability
├── deploy/ kind cluster definition and deployment scripts
├── scripts/ Development loop, prerequisite and dev-certificate scripts
├── local-run/ control-panel.bat, a double-clickable menu
├── openapi/ The committed API contract
├── docs/
│ ├── adr/ Architecture decision records
│ ├── local-development.md
│ └── superpowers/ Specs and implementation plans
└── .claude/ Hooks, agents, skills, and slash commands
- Windows 11 with PowerShell (the helper scripts are PowerShell; everything else is portable)
- .NET SDK 10
- Node.js 24.15 or newer (24.20.0 is what CI pins)
- Docker Desktop
kubectl,kind, and optionallyk9s, for the Kubernetes path only
On a new machine you do not have to install these by hand:
./scripts/install-prereqs.ps1It checks for each tool and installs whatever is missing through winget. It only installs what
is entirely absent; a tool that is present but older than expected is reported rather than
silently upgraded, because upgrading Docker Desktop or Node.js affects every other project on
the machine. Docker Desktop's own first run (WSL2 backend, licence terms, a restart) cannot be
scripted and the script says so when it reaches that point.
The same script is option 1 of local-run/control-panel.bat, a
double-clickable menu that wraps every script in this section.
git clone https://github.com/Surentz/AIFrameWork.git
cd AIFrameWork
dotnet tool restore # dotnet-ef, from .config/dotnet-tools.json
npm install --prefix frontendscripts/dev.ps1 runs npm install by itself when frontend/node_modules is missing, so a
first run needs no separate step.
One command starts PostgreSQL and RabbitMQ, applies migrations, and launches the API, the job worker, and the Vite dev server in windows of their own:
./scripts/dev.ps1 # three windows: API (5234), worker (5235), Vite (5173)
./scripts/dev.ps1 -WithSeq # same, plus Seq for structured logs at localhost:55341
./scripts/worker.ps1 # just the worker, in this window — for restarting it aloneTo stop all of it:
./scripts/stop-dev.ps1The worker is not optional scenery: jobs run there and the API listens to nothing, so
without that window an enqueued job simply sits on its RabbitMQ queue and nothing says so.
worker.ps1 exists because codegen write requires a worker restart before new adapters take
effect, and restarting it otherwise means stopping everything.
By hand, if you want the pieces separately:
docker compose up -d --wait
dotnet ef database update --project src/Infrastructure --startup-project src/Infrastructure
dotnet run --project src/Api
dotnet run --project src/Worker
npm start --prefix frontendThe app opens at http://localhost:5173 and the API reference at
http://localhost:5234/scalar/v1. Both open a browser tab of their own. Vite proxies /api to
the backend, which is what lets the session cookie be same-origin.
Further detail, including one-keystroke startup in Rider and Visual Studio, is in
docs/local-development.md.
No external system is configured by default, so the dev loop needs nothing extra. To try one,
generate a throwaway PKI into the git-ignored .certs/ folder and point a system at the files:
./scripts/new-dev-certs.ps1 # ca.pem, client.pfx/.pass, server.pfx/.pass; -Force to replace$env:ExternalSystems__Systems__Partner__BaseAddress = "https://partner.example/api/"
$env:ExternalSystems__Systems__Partner__ClientCertificate__Path = "$PWD/.certs/client.pfx"
$env:ExternalSystems__Systems__Partner__ClientCertificate__PasswordFile = "$PWD/.certs/client.pass"Secrets are always file paths, never values — in a deployed environment they are files that
Vault Secrets Operator mounts (the contract is in ADR 0031; the manifests are not written yet).
The external-systems skill has the full configuration, including OAuth.
To see the monitoring page with a real partner, ./scripts/dev.ps1 -WithPartners starts the test
partner simulator (mTLS on 55690, health on 55691) from .certs/ (generated if missing) and points
the worker at it. Without the flag the worker has no external system and the page is empty.
A local kind cluster that runs the stack at two API replicas across two worker nodes, to rehearse what only breaks above one: a shared Data Protection key ring, cache eviction under ingress cookie affinity, and a realtime push that has to reach a user whose connection is held by the other pod. Docker Compose remains the inner development loop; this is additive.
./deploy/start-cluster.ps1 # creates the cluster if missing, otherwise redeploys
./deploy/deploy.ps1 # rebuild, migrate, roll out onto an existing cluster
./deploy/deploy.ps1 -WithObservability # same, plus OTel Collector -> OpenSearch, Prometheus, Grafana
./deploy/teardown.ps1 # deletes the cluster, and the data inside itTwo replicas means two failure domains, not two processes: deploy/kind-cluster.yaml declares
a control-plane node plus two workers, and api spreads across them with
whenUnsatisfiable: DoNotSchedule. HPAs cover api and web with minReplicas: 2 as a floor,
and all three workloads have PodDisruptionBudgets. The worker has a budget but no autoscaler
— its work arrives through queues, so queue depth is the signal an autoscaler would need, and
CPU would scale it down exactly when it is blocked and falling behind. See ADR 0018.
-WithObservability is opt-in and is by a wide margin the largest thing in the cluster
(OpenSearch wants real JVM heap, Dashboards is a second Node process) — reach for it only when
the telemetry pipeline itself is what you are rehearsing. It is deliberately not part of the
e2e-k8s.ps1 readiness gate: a log store has no business in the readiness path of an e2e run.
The deployment is three phases in a fixed order, because Kustomize has no hook mechanism: configuration and PostgreSQL first, then migrations as a Job run to completion, and only then the Deployments and the Ingress. An API pod therefore never starts against an unmigrated schema.
Open https://aiframework.localtest.me:8443. That name resolves to 127.0.0.1 publicly, so
there is nothing to add to your hosts file, and the certificate is self-signed so the browser
warns once. The ingress is published on 8080/8443 rather than 80/443 because those ports are
frequently taken on Windows by IIS or BranchCache through http.sys.
Four things that will cost you time are documented in CLAUDE.md: TLS is not
optional in Production because the session cookie is Secure; configuration keys need double
underscores (Cache__Enabled, not Cache_Enabled — a single underscore binds nothing and warns
nothing); the migration Job is deleted before being re-applied, because a completed Job has
immutable fields; and metrics-server is installed patched with --kubelet-insecure-tls,
without which every HPA reports unknown and never scales — as a condition on the HPA, not as
a deploy failure, so the cluster looks healthy while the autoscalers do nothing.
PostgreSQL is pinned to whichever node it first scheduled on by kind's node-local storage class, so draining that node will not reschedule it. Target a different node for a drain test.
The local overlay commits a PostgreSQL password and a self-signed private key on purpose. They are throwaway values for a localhost-only cluster that is never deployed, each file says so in its own header, and neither is a pattern to copy into an overlay that targets a real environment.
| Port | What | Source |
|---|---|---|
| 5173 | Vite dev server | frontend/vite.config.ts |
| 5234 | API | src/Api/Properties/launchSettings.json |
| 5235 | Job worker (health endpoints only) | src/Worker/Properties/launchSettings.json |
| 4173 | Vite preview, used by the e2e suite | frontend/vite.config.ts |
| 55433 | Development PostgreSQL, data persists | docker-compose.yml |
| 55432 | End-to-end PostgreSQL, throwaway | docker-compose.e2e.yml |
| 55672/55673 | Development RabbitMQ: AMQP / management UI (aiframework/aiframework) |
docker-compose.yml |
| 55682/55683 | End-to-end RabbitMQ, throwaway | docker-compose.e2e.yml |
| 55690/55691 | Dev partner simulator: mTLS / health, only with dev.ps1 -WithPartners |
tests/PartnerSimulator |
| 55692/55693 | End-to-end partner simulator: mTLS / health | frontend/playwright.config.ts |
| 55341 | Seq, only with dev.ps1 -WithSeq |
docker-compose.yml (observability profile) |
| 8080/8443 | kind ingress | deploy/kind-cluster.yaml |
The two databases are meant to coexist. The two API processes are not: npm run e2e starts its
own API on 5234 with reuseExistingServer: false, so stop the development API before an e2e run
or set API_PORT.
All endpoints are under /api. Authentication is a HttpOnly, SameSite=Lax session cookie
named aiframework.session; SameSite=Lax combined with JSON-only request bodies is the CSRF
defence, so no antiforgery token is issued.
| Method | Route | Auth | Purpose |
|---|---|---|---|
POST |
/api/auth/register |
anonymous, rate limited | Create an account and sign in |
POST |
/api/auth/login |
anonymous, rate limited | Sign in |
POST |
/api/auth/logout |
cookie | Sign out |
GET |
/api/auth/me |
cookie | The current session |
POST |
/api/auth/change-password |
cookie | Change your own password |
POST |
/api/auth/sign-out-everywhere |
cookie | Invalidate every session for this user |
POST |
/api/orders |
cookie | Place an order |
GET |
/api/orders |
cookie | List your orders, paged |
GET |
/api/orders/{id} |
cookie | One of your orders |
POST |
/api/orders/{id}/cancel |
cookie | Cancel one of your orders; 409 if it has already shipped |
POST |
/api/orders/exports |
cookie | Ask for a PDF of all your orders; 202, built by the worker, notified when ready. Returns the one in progress instead of starting another |
GET |
/api/orders/exports |
cookie | Your exports, newest first, kept for seven days. One still building after 45 minutes reads as Failed |
GET |
/api/orders/exports/{id}/download |
cookie | The PDF; 404 if it is someone else's or not built yet |
GET |
/api/fulfilment/orders |
Orders.Fulfil |
Every buyer's orders in one status, oldest first |
POST |
/api/fulfilment/orders/{id}/ship |
Orders.Fulfil |
Ship any buyer's order — the only way an order ships over HTTP; 409 on an illegal transition |
POST |
/api/products |
Catalogue.Manage |
Add a product to the catalogue |
GET |
/api/products |
cookie | List the catalogue, paged |
GET |
/api/products/{id} |
cookie | One product |
PUT |
/api/products/{id} |
Catalogue.Manage |
Replace a product's editable fields — not the sku |
GET |
/api/notifications |
cookie | Your notification feed, newest first, paged |
GET |
/api/notifications/unread-count |
cookie | Your unread count, for the badge |
POST |
/api/notifications/{id}/read |
cookie | Mark one notification read |
POST |
/api/notifications/read-all |
cookie | Mark every unread notification read |
GET |
/api/rates?from=&to= |
cookie | An exchange rate; 503 once the provider's retry budget is spent |
GET |
/api/statistics/population?area= |
cookie | The latest quarter's population for an area (Statistics Denmark, the external systems pilot); 400 for a malformed area, 404 for an unpublished one, 503 when the source is down |
GET |
/api/statistics/population/areas |
cookie | Every area population figures are published for |
GET |
/api/monitoring/external-systems |
Monitoring.Read |
Each external system's last health check, certificate expiry and outbound traffic; written by the worker (ADR 0032) |
GET |
/api/monitoring/* |
Monitoring.Read |
The operator's views: access, sign-ins, traffic, job runs and dead letters |
POST |
/api/monitoring/jobs/trigger, …/dead-letters/{messageId}/retry |
Monitoring.Operate |
Run a scheduled job now; put a dead letter back in play |
GET/POST |
/api/monitoring/users/* |
Users.Manage |
List accounts, promote or demote, revoke sessions, an account's history |
GET |
/health |
anonymous | Liveness; never touches the database |
GET |
/health/ready |
anonymous | Readiness; checks PostgreSQL. External systems' health checks are deliberately excluded |
Auth is cookie for any signed-in user, or a capability policy that only an Admin holds
(ADRs 0020, 0024, 0025). Controllers name the capability, never the role, so a later permission
model changes one mapping in Program.cs rather than every controller. Administrators are seeded
from Admin__Usernames and managed from the monitoring page.
Orders also ship without anyone calling the API: a warehouse publishes shipment.confirmed.v1 to
RabbitMQ and the worker records it (ADR 0026).
The worker serves /health and /health/ready of its own on 5235, and nothing else — it is a
web host only so that Kubernetes has something to probe.
Both hosts register each configured external system's probe, certificate and token checks,
tagged external, and both /health/ready endpoints filter them out
(ExternalSystemHealth.IsNotExternal). A partner that is down makes its own checks Unhealthy;
it never takes an API or worker pod out of rotation. The worker's status publisher records
those checks every minute and the monitoring page shows them (/monitoring/integrations, ADR
0032); neither endpoint does.
/hubs/notifications is a SignalR hub, mapped only when Realtime__Enabled is on. It is
best-effort by contract: the feed is the truth, and the push only closes the window in which
the badge would otherwise be up to thirty seconds stale. See ADR 0019.
The catalogue is [Authorize] rather than anonymous even for reads, and that is mechanical
rather than a product decision: an ICacheable query dispatched with no current user throws,
because the cache key is composed with ICurrentUser.Id and an unscoped entry would be shared
across every caller.
Orders belong to the user who placed them and are never reassigned (ADR 0007), so a request for someone else's order is a 404 rather than a 403.
Failures are RFC 9457 ProblemDetails throughout, including the rate limiter's 429. Expected
failures never throw: a handler returns a failed Result, and ErrorKind maps to a status code
(Validation → 400, Unauthorized → 401, NotFound → 404, Conflict → 409, Unavailable →
503 with Retry-After). Thrown
DomainExceptions become 400 through the global IExceptionHandler; anything else is a logged
500 whose message is not leaked.
Credential endpoints are protected twice over (ADR 0008): a fixed-window rate limiter partitioned by remote address, and a self-expiring per-account lockout after five failed attempts. The lockout is deliberately silent, so that neither mechanism reveals which accounts exist.
Sessions can be invalidated after the fact (ADR 0011). Each user carries a rotating
SecurityStamp; the cookie carries the stamp it was issued under, and every authenticated
request compares the two in OnValidatePrincipal before the endpoint sees it. Three things
rotate it — a password change, the failed sign-in that locks an account, and
POST /api/auth/sign-out-everywhere — and a rotation ends every cookie already issued for that
user. That read is deliberately uncached and always will be: HybridCache here is L1-only, so
caching it would let a revoked session survive on another replica for the length of the TTL.
Changing your own password re-issues your own cookie, which is why ChangePassword returns a
SessionView rather than a bool.
The interactive reference is Scalar at /scalar/v1, and it is Development only — a deployed
instance must not publish its endpoint surface, and an integration test asserts that in both
directions.
frontend/src/
├── api/ Generated schema, typed client, endpoint wrappers
├── features/
│ ├── auth/ Login, register, change password, RequireAuth and RequireRole guards
│ ├── orders/ List, detail, place-order form, exports page, query hooks
│ ├── products/ Catalogue list, detail, create and edit forms
│ ├── notifications/ The header bell, the feed, and the SignalR stream
│ ├── fulfilment/ The operator's queue of orders to ship
│ └── monitoring/ Overview, jobs, sign-ins, traffic, and user management
├── components/
├── styles/ tokens.css, global.css, controls.css
├── test/ MSW handlers, setup, query-client helper
└── routes.tsx
| Route | Component | Access |
|---|---|---|
/login |
LoginPage |
anonymous, full-bleed |
/register |
RegisterPage |
anonymous, full-bleed |
/ |
redirect to /orders |
authenticated |
/orders |
OrderList |
authenticated |
/orders/new |
PlaceOrderForm |
authenticated |
/orders/exports |
OrderExportsPage |
authenticated |
/orders/:id |
OrderDetail |
authenticated |
/products |
ProductList |
authenticated |
/products/new |
CreateProductForm |
administrator |
/products/:id |
ProductDetail |
authenticated |
/products/:id/edit |
EditProductForm |
administrator |
/notifications |
NotificationList |
authenticated |
/statistics/population |
PopulationPage |
authenticated |
/account/password |
ChangePasswordPage |
authenticated |
/fulfilment |
FulfilmentPage |
administrator |
/monitoring |
MonitoringPage |
administrator |
/monitoring/jobs, /logins, /traffic, /users |
JobsPage, LoginsPage, TrafficPage, UsersPage |
administrator |
/monitoring/integrations |
IntegrationsPage |
administrator |
/products/new and /orders/exports are declared before /products/:id and /orders/:id, so
that new and exports match their literal routes rather than being captured as an id.
RequireAuth wraps the layout rather than the other way round, so a signed-out visitor is
redirected before a header they cannot use is rendered. RequireRole allow="Admin" nests inside
it for the administrator routes; it only hides UI, and the API's policies are what refuse the
request. Server state is TanStack Query; there is
no client state library, because there is no client state worth the dependency.
npm start --prefix frontend # dev server on 5173
npm run build --prefix frontend # tsc -b && vite build
npm test --prefix frontend # vitest (watch); add -- --run for one pass
npm run lint --prefix frontend # eslint, zero warnings tolerated
npm run e2e --prefix frontend # PlaywrightThree artifacts are generated, committed, and re-checked by CI. Each exists because the failure it prevents is invisible in a normal build.
The API contract. openapi/AiFramework.Api.json is generated from the running application
and frontend/src/api/schema.d.ts from that. After changing a controller, a DTO, or a
[ProducesResponseType]:
dotnet restore src/Api
ConnectionStrings__Default='Host=localhost;Port=55433;Database=placeholder;Username=x;Password=y' \
Wolverine__Durable=false Admin__ReconcileOnStart=false \
dotnet msbuild src/Api -t:"Build;GenerateOpenApiDocuments"
npm run generate:api --prefix frontendGeneration runs the whole application, which is why all three environment variables are required:
the startup guard rejects an empty connection string, a durable Wolverine dials PostgreSQL, and so
does the startup administrator reconciler. The connection string is never actually opened, and no
broker is needed — RabbitMQ is configured only when Wolverine is durable. It is an explicit MSBuild target rather than part of
dotnet build, so that an ordinary build stays ordinary.
Wolverine handler adapters — there are TWO trees, not one. Wolverine builds its adapters
with Roslyn, and Release ships without the compiler because it costs 33MB (measured: a Release
publish is 17MB without it, 50MB with). Release instead loads adapters generated ahead of time.
TypeLoadMode.Static resolves them out of each host's own opts.ApplicationAssembly, and the
worker cannot share the API's — that would need the Worker → Api reference the dependency rule
forbids. After adding or changing a handler, regenerate the tree(s) it belongs to:
dotnet run --project src/Api -- codegen write # event-path handlers
dotnet run --project src/Worker -- codegen write # job handlers, and JobUserMiddlewareThis is the trap the Release dimension in CI exists for: stale generated code leaves Debug green and the build succeeding, and fails only in Release, at startup. Debug stays green with either tree stale, and CI checks both.
CI's Linux output is the authority, and Windows does not always match it. If a Windows regeneration reorders statements inside a handler you did not change, keep the committed version — the two orders do the same thing, each platform is stable, and CI's "generated code is current" job is what decides.
EF Core migrations. Never hand-edit an applied migration; add a new one.
.claude/hooks/protect-migrations.ps1 enforces this against git history, so a migration you have
just generated and not yet committed is still yours to adjust, while one that is in HEAD is
refused even if you delete the file first.
dotnet test # five test projects; needs Docker for Postgres, RabbitMQ and Keycloak
npm test --prefix frontend -- --run # Vitest
npm run e2e --prefix frontend # PlaywrightInside Claude Code, /verify runs all of the above plus both lint steps and the two
generated-artifact drift checks, and reports what actually ran — skipping any toolchain that
is not installed rather than reporting a false pass.
| Suite | What belongs there |
|---|---|
Domain.Tests |
Invariants and domain events, no infrastructure |
Application.Tests |
Handlers against substituted ports; architecture tests |
Infrastructure.Tests |
Repositories, outbox, caching, against real PostgreSQL via Testcontainers; external systems over real TLS against the in-process partner simulator and a Keycloak container |
Api.IntegrationTests |
The real pipeline through WebApplicationFactory, including authorization policies |
Worker.IntegrationTests |
Job delivery, Quartz scheduling, inbound shipments, and the worker's own codegen |
frontend/src/**/*.test.tsx |
Components and hooks, with MSW standing in for the API |
frontend/e2e/specs/**/*.spec.ts |
Browser journeys against a built preview bundle, the worker and a real broker |
Testcontainers needs a running Docker daemon; both integration projects start a RabbitMQ container beside Postgres, because a durable host cannot boot without a broker. The caching behavior, the rate limiter, and the resilience pipeline's retries are all disabled by default under test and re-enabled by the specific suites that exercise them, so that none becomes an intermittent failure — or a source of backoff delay — in tests that are not about them.
Several suites exist specifically to catch a class of regression that review alone would miss:
JobRegistrationTests fails the build on an unregistered job, ApiPublishesOnlyTests asserts
against the runtime's own endpoint list that no aiframework.jobs.* queue has a listener on the API host,
SensitiveCommandLoggingTests catches a logging change that would write plaintext passwords to
the log store, and WolverineCodegenTests/WorkerCodegenTests catch stale adapters in Debug —
where they would otherwise stay invisible until Release. For external systems,
NoAcceptAnyCertificateTests fails on any certificate-validation callback under src/ (the
only way to write "accept any certificate"), HandlerChainTests pins the handler order, and
ReadinessTests in both hosts prove an unreachable partner leaves /health/ready at 200.
Certificate tests are only proven on Linux. Windows can complete a certificate chain from its own store, so a TLS test that passes locally may still fail in CI's Linux jobs. That has happened once already, and CI is the authority here.
.github/workflows/ci.yml runs on every push to main and every
pull request:
| Job | What it proves |
|---|---|
backend (Debug) / backend (Release) |
Builds and tests in both configurations; Debug also fails on unformatted C# (dotnet format whitespace --verify-no-changes) |
generated code is current |
Re-runs codegen write for both trees and fails on any diff |
api contract is current |
Regenerates the OpenAPI document and the TypeScript schema, and fails on any diff |
frontend |
Lint, build, and Vitest |
e2e / e2e |
Playwright against the full stack, gated behind backend and frontend; also runnable by hand from the Actions tab |
pr title and labels |
From pr-labels.yml: the title is a Conventional Commit, and the PR is labelled from it and the files changed |
Every one of these must pass before a pull request can merge into main. The branch ruleset
requires them by name, so renaming a job means updating the ruleset in the same change.
CodeQL scans C#, TypeScript and the workflows on every pull request and weekly, from
repository settings (default setup) rather than a workflow file, so there is no codeql.yml —
GitHub rejects one while default setup is on. Results land in the Security tab; it is not a
required check.
Release is a separate matrix leg rather than an afterthought: Release was broken in this
repository for the whole life of the Wolverine spike without anyone noticing, because
dotnet build succeeded with zero warnings and only the startup failed.
The two "is current" jobs fail on a diff, not on a build error, which is what lets a working
tree build, test, and lint clean while CI rejects it. /verify runs both as its step 3 for
exactly that reason — so the one thing CI does that a local /verify does not is the Release
leg.
Every pull request gets one comment with its test results and coverage. It is edited in place on each push, and shows:
- a result per suite (✅, ❌, or the number of tests that passed only on retry), with passed, failed, skipped, total and duration;
- the failed tests by name;
- backend and frontend line, branch and method coverage, against main's last green run, and the backend per assembly.
CI's last job, test report, builds it from the artifacts the other jobs uploaded, using
pr-report.js. It reports and never blocks: it is not a required
check. Backend coverage covers unit
and integration tests together and leaves out generated code (the Wolverine adapters, EF
migrations). The full HTML report is the coverage-backend artifact. To get the same numbers
locally:
dotnet test --settings tests/coverage.runsettings --collect "XPlat Code Coverage" --results-directory TestResults
dotnet reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:TestResults/coverage
npm test --prefix frontend -- --run --coverage # report in frontend/coverage/Non-negotiable, and mostly machine-checked:
- Warnings are errors, from the compiler, the analyzers, and the build. Repository-wide
exemptions live in
.editorconfig; anything narrower is a local#pragma warning disable/restorepair with a justification comment above it. - Nullable is enabled. A missing null check does not compile.
requiredinDomain, never[Required]. DataAnnotations belong on Api DTOs.- Never
catch (Exception). The globalIExceptionHandlerreceives it as a parameter.throw;, neverthrow ex;. CA1031 is an error everywhere except the outbox's background pumps, which have no such parameter and must not die mid-loop. - No secrets in
appsettings*.json. Usedotnet user-secretsor environment variables. A pre-edit hook refuses the write (not the commit — nothing here hooks git). The committed development connection string and the local Kubernetes overlay are deliberate, documented exceptions: throwaway credentials for localhost-only containers that are never deployed. - Never hand-edit an applied EF migration. Add a new one. The hook keys on git history, so a
migration you have just generated and not yet committed is still yours to adjust, while one
that is in
HEADis refused even if you delete the file first. - Handlers do not log their own outcome. Every command and query is already wrapped by a
logging behavior that records outcome and duration, and it logs
typeof(TRequest).Namerather than the request instance —SignIn,RegisterUser, andChangePasswordall carry a plaintext password field. See ADR 0015. - An external system's secrets are file paths, and nothing accepts any certificate.
Certificates, passwords and client secrets are mounted files named in configuration; server
trust is a per-system CA bundle through
CertificateChainPolicy, never a validation callback. See ADR 0031.
Per-language detail lives in the dotnet-conventions, dotnet-testing, react-conventions, and
react-testing skills; regenerate and jobs cover the two procedures most likely to be got
wrong — which generated artifact to rebuild after a change, and the job framework's traps — and
external-systems covers adding a partner. Each layer has a CLAUDE.md of its own.
All accepted, in docs/adr/.
| # | Decision |
|---|---|
| 0001 | Record architecture decisions |
| 0002 | Clean Architecture with a machine-enforced dependency rule |
| 0003 | In-process messaging without MediatR |
| 0004 | React over Angular |
| 0005 | Wolverine for the event path, on PostgreSQL, without a broker |
| 0006 | Username and password authentication, on a session cookie |
| 0007 | Orders belong to the user who placed them |
| 0008 | Brute-force protection: rate limiting and a self-expiring lockout |
| 0009 | Query caching: an opt-in pipeline behavior, scoped to the caller |
| 0010 | Running on Kubernetes: shared state across two replicas |
| 0011 | Session invalidation on a rotating security stamp |
| 0012 | End-to-end test architecture |
| 0013 | A global catalogue behind a caller-scoped cache |
| 0014 | Retry and resilience policies on the request path |
| 0015 | Centralized logging with MEL and OpenTelemetry |
| 0016 | Jobs run in a worker host, with lanes as queues (the transport superseded by 0026) |
| 0017 | Quartz.NET as the job clock |
| 0018 | Load balancing within the affinity constraint |
| 0019 | Realtime notifications over SignalR, with a Redis backplane |
| 0020 | An administrator role |
| 0021 | Operational telemetry in Postgres |
| 0022 | Configuration seeds the administrator list, rather than mirroring it |
| 0023 | The e2e stack starts the job worker |
| 0024 | Capability-named policies, and the operator ships |
| 0025 | The catalogue is managed by administrators |
| 0026 | RabbitMQ is the broker for all asynchronous work |
| 0027 | OpenTelemetry metrics beside operational telemetry |
| 0028 | The outbox is delivered only where the push transport lives |
| 0029 | Order exports are stored in Postgres and fail by staleness |
| 0030 | Order exports are PDFs rendered by MigraDoc |
| 0031 | Outbound integrations with external systems: mTLS, OAuth 2.0, one fixed handler chain |
| 0032 | External system status is published by the worker, not by a job |
Record a new one with /adr <title>. Check the open branches as well as docs/adr/ before
taking a number — two branches that each take "the next one" produce a duplicate, which has
happened once already and needed a renumbering commit to undo.
dotnet ef cannot see user-secrets. Migrations run through
DesignTimeDbContextFactory, which reads only the ConnectionStrings__Default environment
variable, so a database update against anything but the default needs it passed explicitly.
The reverse also bites: a user-secret sits above appsettings.Development.json in configuration
order, so a stale value there wins at runtime while dotnet ef ignores it. Check with
dotnet user-secrets list --project src/Api when the app and the migrations disagree about
which database they are using.
'vite' is not recognized. frontend/node_modules is missing. Run
npm install --prefix frontend, or just use scripts/dev.ps1, which does it for you.
Cannot find native binding from rolldown. A global ~/.npmrc pinning os= or cpu= to
the wrong platform makes npm skip every platform-specific optional dependency silently. Remove
the line, delete node_modules and package-lock.json, and reinstall.
scripts/install-prereqs.ps1 flags this.
The browser drops the session cookie on Kubernetes. ASPNETCORE_ENVIRONMENT=Production sets
CookieSecurePolicy.Always, so over plain HTTP login appears to succeed and every later request
is a 401 with nothing in the logs. Use the HTTPS ingress.
kind create cluster fails to bind port 80. Something is holding it through http.sys,
usually IIS or BranchCache. The cluster definition already publishes 8080/8443 instead; if you
changed that back, either free the port from an elevated shell or restore the remap.
Certificate tests start failing on Windows after many runs. The .NET TLS stack on Windows
copies each test PKI's intermediate certificate into Cert:\CurrentUser\CA, and at about a
hundred same-named certificates chain building fails. The test PKI removes its own certificates
when disposed, so this now grows by well under one per run. If it ever bites, delete the
AiFramework … Intermediate CA and … Root CA test certificates from that store. Never delete
anything else.
A TLS handshake fails with PartialChain. The server did not send its intermediate
certificate and the client trusts only the root. Windows often hides this by finding the
intermediate in its own store; Linux does not. For a partner, it is the partner's server
configuration to fix.
A Vite proxy ECONNREFUSED for /api/... at startup. The frontend came up before the API
finished starting. scripts/dev.ps1 pauses five seconds between the two for exactly this;
reload once and it resolves.
- Read
CLAUDE.mdand theCLAUDE.mdof the layer you are touching. - Scaffold with
/feature <name>,/react-feature <name>, or/job <Name>so the layering and the tests come out right the first time. - Keep the dependency rule intact. The hook will stop you on Windows — but it is a backstop, not a design tool, and on Linux or macOS it is not running at all.
- Regenerate and commit any affected generated code: the API contract after
touching a controller, a DTO, or a
[ProducesResponseType]; the Wolverine tree(s) after touching a handler. - Run
/verifybefore opening a pull request. CI runs the same checks plus the Release configuration. - Record anything architecturally load-bearing with
/adr <title>.
Issues and pull requests go to https://github.com/Surentz/AIFrameWork.
Apache License 2.0. The copyright line in LICENSE is still the unfilled template
placeholder; set it before publishing this anywhere that matters.