A single-user async LLM chat platform: submit a batch of prompts, track each through Pending → Processing → Completed / Failed, and retry or cancel inline. Processing happens off the request path — a worker dequeues each prompt, calls the LLM, and publishes the result back through RabbitMQ while the frontend polls for updates.
This is a portfolio project built to exercise production patterns end to end across a non-trivial multi-service system. Every component can be tested offline without an API key.
Browser ──► nginx ───┬── /v1/prompts ──► Prompts API ──► PostgreSQL
(TLS, │ /health, /swagger ▲
:34443) │ │ │
│ │ (transactional outbox) │ (MassTransit
│ ▼ │ consumers)
│ RabbitMQ ◄──────────────────────┤
│ │ │
│ │ (ProcessPromptCommand) │
│ ▼ │
├── /connect ──► Identity ──► PostgreSQL
│ /register,
│ /.well-known/openid-configuration
│
└── SPA (React, static files)
Six containers: PostgreSQL 16, RabbitMQ 3, the Identity and Prompts APIs (ASP.NET Core minimal APIs), a Worker (generic host), and an nginx gateway that serves the React SPA and reverse-proxies all API traffic over a single TLS origin at localhost:34443.
A prompt submission flows through four hops:
- Browser →
POST /v1/promptsthrough nginx toapi.JjChat.Prompts. SubmitPromptsHandler(backend/src/api.JjChat.Prompts/Features/Prompts/SubmitPrompts/SubmitPromptsHandler.cs) writes the newPromptrows to the Prompts database inside a transaction. From the same transaction, MassTransit publishesProcessPromptCommandto RabbitMQ via its outbox — the message is committed atomically with the domain write.- Worker dequeues
ProcessPromptCommandand broadcastsPromptProcessingStarted, then callsILlmClient.CompleteAsync(), then publishesPromptProcessingFinished(success or failure). A retryable failure throwsLlmRetryableException, which MassTransit retries with backoff. Consumer:backend/src/JjChat.Worker/Consumers/ProcessPromptConsumer.cs. - Prompts API consumes the status events back from RabbitMQ and updates the
Promptaggregate with an optimistic-concurrency guard (backend/src/api.JjChat.Prompts/Infrastructure/Messaging/). The frontend pollsGET /v1/promptsevery 2 seconds to reflect the new status.
Cancel and retry go through the API directly — Cancel transitions Pending → Cancelled synchronously; Retry resets a Failed prompt back to Pending so the worker picks it up again.
| Layer | Technology | Why |
|---|---|---|
| Runtime | .NET 10, C# 14 | Latest LTS; primary constructors, collection expressions, file-scoped namespaces throughout |
| HTTP | ASP.NET Core minimal APIs | Map-grouped endpoints with typed results; trimming-ready |
| Architecture | Vertical Slice Architecture | One folder per use-case containing its endpoint, handler, request, response, and validator — no shared service layer |
| Mediator | Hand-rolled (JjChat.Common/Broker/) |
~160 lines; demonstrates CQRS command/query dispatch, decorator pipeline, runtime-compiled expression-tree invokers cached per message type, and interlocked handler-registration — without pulling in MediatR |
| Messaging | MassTransit + RabbitMQ | Transactional outbox (PostgreSQL) for atomic DB-write-and-publish; competing consumer on the worker side |
| Persistence | EF Core + Npgsql (PostgreSQL 16) | Real server per test via Testcontainers template/clone; migrations run on boot |
| Auth | OpenIddict (OAuth2 / OpenID Connect) | Password and refresh-token grants; public client jjchat-spa; read/write scope enforcement; split-horizon issuer (public https://localhost:34443 vs internal discovery at https://web:8443) |
| Validation | FluentValidation | One validator per slice request, invoked through the mediator's ValidationDecorator |
| Resilience | Polly v8 | Per-HTTP-call retry pipeline on the worker: 3 attempts with exponential backoff + jitter; transient classification distinguishes 408/429/5xx from permanent 4xx |
| LLM client | DeepSeek API (OpenAI-compatible) | ILlmClient abstraction with a FakeLlmClient that echoes the prompt — the full pipeline runs without an API key |
| Rate limiting | ASP.NET Core fixed-window | Per-user partition for authenticated requests, per-IP for anonymous; separate token-endpoint and write-endpoint policies |
| Frontend | React 19, TypeScript, Vite 6 | Client-side routing (React Router 8), Zod schema validation on API responses, TailwindCSS 4 |
| Testing | xUnit + FluentAssertions + Testcontainers | Vitest + Testing Library + MSW on the frontend; Storybook 10 for component isolation |
Each pattern is exercised with concrete code, not abstracted away behind a library.
Every use-case is a self-contained folder under Features/. A slice for submitting prompts contains:
SubmitPrompts/
SubmitPromptsCommand.cs # ICommand<Result<long[]>>
SubmitPromptsRequest.cs # HTTP DTO
SubmitPromptsValidator.cs # FluentValidation validator
SubmitPromptsHandler.cs # ICommandHandler — writes entities + dispatches
SubmitPromptsEndpoint.cs # Minimal API MapPost
There are five prompt slices (Submit, List, Get, Cancel, Retry) and two identity slices (Register, Token). Architecture fitness tests (backend/tests/JjChat.ArchitectureTests/) verify that every slice has complete anatomy, that no feature references EF Core directly, that all endpoints require authorization unless explicitly allowlisted, and that the dependency graph stays inward-facing.
Mediator.cs (backend/src/JjChat.Common/Broker/) accepts ICommand<TResult> and IQuery<TResult>, resolves the matching handler via a ConcurrentDictionary-backed invoker cache, and chains handlers through a decorator pipeline. Compile-time expression trees build the invoker delegates so dispatch is allocation-light. Handler registration validates at construction: conflicting handlers for the same message type throw, and a result-type mismatch between handler and command throws a descriptive error.
SubmitPromptsHandler begins a transaction, saves the new prompts, publishes ProcessPromptCommand through MassTransit's outbox, and commits — all in one atomic unit. If the outbound publish fails, the prompts are rolled back. If the commit succeeds, the message is guaranteed to be delivered. The worker publishes status events (Started, Finished) by calling context.Publish(...) directly on the MassTransit consume context.
Three decorators wrap every mediator call in backend/src/JjChat.Common/Decorators/:
- LoggingDecorator — logs message type before and after execution (including on exception).
- TimingDecorator — logs wall-clock elapsed time for every handler invocation.
- ValidationDecorator — resolves a
FluentValidationvalidator for the message type from DI; a validation failure short-circuits with aResultcarrying the errors, without calling the handler.
Result<T> and Result records (backend/src/JjChat.Common/Primitives/Result.cs) carry IsSuccess, an optional Value, and an Error with a ResultCategory string. Handlers return Result<T>, never throw for domain failures. The ResultCategoryMapping layer (backend/src/JjChat.Common/Http/ResultCategoryMapping.cs) maps categories to HTTP status codes — Validation → 400, NotFound → 404, AccessDenied → 404 (collapsed to prevent enumeration oracles), Conflict → 409. Decorators honour the IFailureResult<TSelf> constraint so the pipeline can construct failure results without knowing the concrete type.
Every mutation handler that takes a resource id from the request verifies ownership: if the Prompt is owned by a different user, the handler returns AccessDenied, which the HTTP layer maps to 404 — indistinguishable from genuinely missing. Architecture tests assert that every endpoint requires authorization.
ILlmClient has one method: CompleteAsync(string, CancellationToken) → Task<LlmResult>. DeepSeekLlmClient calls the DeepSeek chat-completions endpoint (OpenAI-compatible JSON) behind a Polly resilience pipeline (3 retries, exponential backoff, jitter) with transient HTTP classification (retry on 408, 429, ≥500, and network errors; fail permanently on other 4xx). FakeLlmClient echoes the prompt immediately. The worker selects the implementation at startup:
DEEPSEEK_API_KEYset +LLM_PROVIDER=deepseek(orauto) → real DeepSeek.LLM_PROVIDER=fake→FakeLlmClientechoes the prompt.- No API key +
LLM_PROVIDERleft blank orauto→ the launcher setsLLM_PROVIDER=fakeon first run and prints a notice. If you explicitly setLLM_PROVIDER=deepseekwithout a key, the worker fails fast.
The Identity service issues tokens with iss: https://localhost:34443 (the public origin the browser sees). The Prompts API validates tokens against the same issuer but must fetch the OpenID discovery document and JWKS over the internal Docker network — where localhost:34443 resolves to the container itself, not the gateway. The solution: Identity__ConfigurationUri is set to https://web:8443/.well-known/openid-configuration (the nginx container's internal name). OpenIddict serves the discovery document at both URLs; the Prompts API validates the iss claim against the public origin while fetching keys over the internal route.
No secret is committed to the repository. run.sh / run.ps1 generate .env, database passwords, OAuth2 signing and encryption certificates, and TLS gateway certificates on first run. The certificate authority is name-constrained to localhost and web with a 90-day validity. See SECURITY.md for the full analysis.
Tests are organised in six layers. The stack does not need to be running for any test suite — Testcontainers spin ephemeral infrastructure as needed.
| Project | Count | What it covers |
|---|---|---|
JjChat.Common.Tests |
27 | Mediator dispatch, decorator ordering, Result semantics |
JjChat.Worker.Tests |
52 | ILlmClient implementations (DeepSeek + Fake), retry/transient classification, LlmResult semantics, ProcessPromptConsumer behaviour, startup wiring by env |
api.JjChat.Prompts.Tests |
149 | Every prompt handler (Submit, List, Get, Cancel, Retry) including rollback and access-denied paths; every validator; the Prompt aggregate state machine (all transitions, every invalid transition); outbox message consumers; caching decorator with TTL expiry |
api.JjChat.Identity.Tests |
33 | Register handler + validator, settings validation, token-endpoint handler |
Handler tests run against a real PostgreSQL database via Testcontainers. PromptsHandlerTestBase calls SharedPostgres.GetOrCreateTemplateAsync("prompts", PromptsDbMigration.RunAsync) to create a migrated template database once, then clones it per test class (SharedPostgres.CloneAsync), so every test class gets its own isolated database at trivial cost. TestDb is a small arrange/assert helper over DbContext that clears the change tracker after every write, preventing tests from passing on cached entities. The JjChat.Testing.Common project provides shared infrastructure: ResultAssertions, TestDb, SharedPostgres, MockObject<T>, MockLogger<T>, MockConsumeContext, MockUnitOfWorkTransaction, and test builders.
| Project | Count | What it covers |
|---|---|---|
api.JjChat.Prompts.Tests (integration) |
included above | Full HTTP pipeline via WebApplicationFactory: submit/list/get/cancel/retry through the real stack with a real PostgreSQL database (Testcontainers), TestAuthHandler providing controllable per-request identity, Respawn-based database reset between tests. Assertions cover both the HTTP response and the persisted state. Rate-limiting, OAuth2 scope enforcement, optimistic-concurrency version conflicts, and health/status endpoints are all exercised. |
api.JjChat.Identity.Tests (integration) |
included above | Full OAuth2 token grant (password + refresh), registration flow, duplicate-email conflict, enumeration-guard tests (unknown email vs wrong password produce identical responses), rate limiting on token and register endpoints. |
JjChat.EndToEnd.Tests (2 tests) runs a self-contained stack: a real Identity server (WebApplication on a loopback port), a Prompts WebApplicationFactory, a Worker IHost, a RabbitMQ Testcontainer, and cloned PostgreSQL databases. The main test registers an account, obtains an OAuth2 access token, submits prompts, and polls until every prompt reaches Completed — exercising the full async pipeline including the worker, outbox, and RabbitMQ round-trip. The second test verifies that an unauthenticated request returns 401.
Three test scripts under tests/shell/ verify the launcher infrastructure in isolation — no Docker needed:
test_browser.sh— theopen_urlsfallback logic when no browser is available.test_credentials.sh— credential generation produces distinct, valid-shape demo accounts across iterations.test_env_merge.sh— the.envmerge logic preserves operator-supplied values, appends missing keys, handles base64 roundtrips and shell metacharacters, and is idempotent.
JjChat.ArchitectureTests (11 tests) verifies structural invariants against the compiled assemblies:
- Every command/query returns
ResultorResult<T>. - Domain types reference no infrastructure assemblies.
- Every handler and validator is resolvable from DI.
- Every endpoint requires authorization or is on an explicit allowlist.
- Every endpoint has OpenAPI documentation.
- Feature slices do not reference EF Core directly.
- All feature slices have complete anatomy (endpoint + handler + request + response + validator).
- Every
FluentValidationvalidator targets a mediator message type.
130 tests across 24 files (Vitest + jsdom + Testing Library + MSW):
- Service layer —
HttpPromptService,HttpAuthService,MockPromptService,MockAuthService: API contract, OAuth2 token refresh with concurrent 401 deduplication,ProblemDetailserror extraction, Zod schema validation. - Hooks —
usePrompts,usePolling,useSystemStatus,usePromptFilter: data fetching, polling lifecycle (pause on hidden tab), status computation, filter logic. - UI components —
SubmitConsole,PromptList,PromptRow,PromptFilterBar,StatusBar,AppHeader: user interactions, edge cases (max prompts, max text length, blank-line filtering), action button visibility per status. - Pages —
DashboardPage,LoginPage,RegisterPage: full-page render with mock services, form submission, error surfaces, navigation. - Accessibility —
vitest-axechecks on every visual component and page.
Storybook (10) provides an isolated component workspace with a11y addon at :6006.
GitHub Actions (ci.yml) runs on every push and PR to master:
- Backend — restore, build (Release), and
dotnet testthe full solution. - Frontend —
npm ci,npm run lint,npm run test:ci.
- The demo Certificate Authority is name-constrained to
localhostandweb, valid for 90 days, and generated per checkout. The gateway certificate is served by nginx only. - No secret, API key, certificate, or password is committed to the repository. Everything is generated by
run.sh/run.ps1on first run. - OAuth2 password and refresh-token grants with scope enforcement (
prompts.read/prompts.write). The token endpoint is rate-limited independently. - Aggregate-access guards on every mutation: the caller's user id is verified against the resource's owner, with 403 collapsed to 404 at the HTTP boundary.
- Enumeration guards on login/token endpoints: a precomputed dummy
PasswordHasher<User>hash equalises the dominant cost for unknown emails — PBKDF2 still runs, so unknown-email and wrong-password responses are indistinguishable in timing. - CSP,
X-Content-Type-Options,X-Frame-Options, andserver_tokens offon the nginx gateway; unknownHostheaders are rejected with 444. - Swagger is gated behind a feature flag; the gateway is bound to
127.0.0.1so it is not reachable from the network.
See SECURITY.md for the full analysis including the demo CA assessment and the case against importing it into your system trust store.
- Docker Engine 24+ with Compose v2 (
docker compose version) - ~4 GB free RAM, ~5 GB free disk
- Host ports 34443 and 34080 free
- No .NET SDK, Node, or API key — everything builds inside Docker
A DeepSeek API key is optional. The launcher handles this for you — on first run, if DEEPSEEK_API_KEY is blank and LLM_PROVIDER is blank or auto, it sets LLM_PROVIDER=fake in .env and prints a notice that completions are simulated. The full pipeline still runs end to end. To use a live key, add DEEPSEEK_API_KEY=sk-... to .env and set LLM_PROVIDER=deepseek (or auto).
./run.sh # WSL / Linux / macOS
.\run.ps1 # Windows 11 (PowerShell)The script performs seven steps in sequence:
- Docker preflight check
- Bootstrap — creates
.envfrom.env.exampleif absent, generates certificates and credentials viadocker/certs/generate.sh, merges them without overwriting operator-supplied values docker compose up --build -d— starts all six containers- Readiness polling (up to 3 minutes)
- End-to-end smoke test — registers a demo account, obtains an OAuth2 token, submits a prompt, polls for completion
- Prints a banner with URLs and credentials
- Opens the SPA and Swagger in the browser
The first run pulls base images and builds from source; subsequent runs use Docker layer caching.
Important: running docker compose up --build directly, without the script, fails because backend/Dockerfile.Api:17 copies docker/certs/ca.crt from the build context. Run ./run.sh once, or generate certificates manually:
docker run --rm --entrypoint sh -v "$PWD/docker/certs:/certs" alpine:3 /certs/generate.sh| What | Where |
|---|---|
| SPA (app) | https://localhost:34443/ |
| Prompts API Swagger | https://localhost:34443/swagger |
| Identity Swagger | https://localhost:34443/identity/swagger |
| OpenID discovery | https://localhost:34443/.well-known/openid-configuration |
http://localhost:34080 returns a 301 redirect to https://localhost:34443. The browser will show a certificate warning on first visit — the gateway uses a self-signed certificate. Accept it once and every path works.
./run.sh down # stop and remove containers
docker compose down -v # also delete the database volumeCreate a docker-compose.override.yml (git-ignored) to publish infrastructure ports:
services:
postgres:
ports: ["5432:5432"]
rabbitmq:
ports: ["5672:5672", "15672:15672"]Then start only the infrastructure and run each service locally:
docker compose up -d postgres rabbitmq
dotnet run --project backend/src/api.JjChat.Identity/api.JjChat.Identity.csproj # Terminal 1
dotnet run --project backend/src/api.JjChat.Prompts/api.JjChat.Prompts.csproj # Terminal 2
dotnet run --project backend/src/JjChat.Worker/JjChat.Worker.csproj # Terminal 3
npm --prefix frontend run dev # Terminal 4Both API services auto-migrate their databases on boot.
Port 34443 already in use: sudo lsof -i :34443 (Linux/macOS) or netstat -ano | findstr :34443 (Windows) to find the conflicting process.
Readiness times out: the 3-minute window covers startup only. Run docker compose ps to see which service is unhealthy, then docker compose logs <service>.
JjChat/
├── backend/
│ ├── JjChat.slnx
│ ├── src/
│ │ ├── JjChat.Common/ # Shared kernel: mediator, decorators, primitives
│ │ │ ├── Broker/ # Hand-rolled CQRS mediator
│ │ │ ├── Decorators/ # Logging, timing, validation pipeline
│ │ │ ├── Http/ # Result → HTTP status mapping
│ │ │ └── Primitives/ # Result, Result<T>, BaseEntity
│ │ ├── JjChat.Contracts/ # Shared message contracts (ProcessPromptCommand, events)
│ │ ├── api.JjChat.Identity/ # Identity service (OpenIddict OAuth2)
│ │ │ └── Features/Connect/ # Register, Token slices
│ │ ├── api.JjChat.Prompts/ # Prompts API (VSA slices)
│ │ │ ├── Domain/ # Prompt aggregate
│ │ │ ├── Features/Prompts/ # Submit, List, Get, Cancel, Retry
│ │ │ └── Infrastructure/ # Messaging consumers, caching
│ │ └── JjChat.Worker/ # Background worker
│ │ ├── Consumers/ # ProcessPrompt consumer
│ │ ├── Llm/ # ILlmClient, DeepSeek, Fake
│ │ └── Resilience/ # Retry policy, transient classifier
│ └── tests/
│ ├── JjChat.Common.Tests/ # Mediator, decorator, result tests
│ ├── JjChat.Worker.Tests/ # LLM client, consumer, startup tests
│ ├── JjChat.ArchitectureTests/# Slice anatomy, DI, auth, dependency direction
│ ├── JjChat.EndToEnd.Tests/ # Full-pipeline E2E: register → token → submit → completed
│ ├── api.JjChat.Prompts.Tests/# Handler, aggregate, integration, consumer tests
│ ├── api.JjChat.Identity.Tests/# Handler, integration, settings tests
│ └── JjChat.Testing.Common/ # Shared test infra (builders, assertions, helpers)
├── frontend/
│ ├── src/
│ │ ├── app/ # App shell, protected route
│ │ ├── contexts/ # Auth context (token lifecycle)
│ │ ├── hooks/ # usePrompts, usePolling, useSystemStatus, usePromptFilter
│ │ ├── models/ # Zod schemas for API responses
│ │ ├── pages/ # Dashboard, Login, Register
│ │ ├── services/ # HttpPromptService, HttpAuthService, mocks
│ │ └── ui/ # SubmitConsole, PromptList, FilterBar, StatusBar, PromptRow, AppHeader
│ └── nginx.conf # TLS, reverse proxy, CSP, security headers
├── docker/
│ ├── certs/generate.sh # Per-checkout TLS CA + gateway cert generation
│ └── initdb/ # PostgreSQL role/database bootstrap
├── tests/shell/ # Shell-level tests for launcher infrastructure
├── .github/workflows/ci.yml # GitHub Actions: backend (build + test) + frontend (lint + test)
├── run.sh / run.ps1 # Single-command launcher
├── SECURITY.md # Full security analysis
└── docker-compose.yml
MIT — see LICENSE.
Each of these is a conscious scoping choice with a seam already in place:
- OpenTelemetry — the mediator timing decorator and MassTransit diagnostics headers are the hooks; add an exporter and collector.
- SignalR / WebSocket real-time updates — polling was chosen for simplicity (stateless, no sticky-session requirement). The
GET /v1/promptsendpoint andusePollinghook are the seams. - Per-service auth split behind an API gateway — nginx routes by path prefix today. An API gateway (YARP) would centralise token enforcement and rate limiting.
- Dead-letter dashboards — RabbitMQ management is an HTTP interface on port 15672 that is not published by default; temporarily publish it (see "Local development" above) or add a dashboard/alerting rule. A UI addition.
- Multi-model routing —
ILlmClientaccepts configuration per instance. Registering a named-client factory and routing by prompt metadata fits without touching consumers. - HSTS — deliberately absent; pinning HSTS on
localhostwould affect every other project served on localhost, which is a hostile side effect for a demo.