Item-exchange (barter) platform. Users list items they're willing to trade, browse what others have, and negotiate two-sided trades in real time. A transaction is sealed only when both parties approve the exchange.
The codebase started life as a netcoreapp3.1 + CRA 3.4 / React 16 tutorial-derived app deployed on Azure App Service. It's now a .NET 10 LTS API (clean architecture, MediatR vertical slices, EF Core 10 + Postgres, SignalR) and a Vite + React 19 + Mantine 9 SPA, packaged as a single multi-stage Docker image and deployed to a self-hosted k3s cluster. See § Project status for what's done, in progress, and on the backlog.
| Layer | Choice |
|---|---|
| Backend runtime | .NET 10 LTS (released Nov 2025; supported through Nov 2028) |
| Language | C# 13, TypeScript 5.x (strict) |
| Web framework | ASP.NET Core 10 (Kestrel + MVC controllers) |
| ORM | EF Core 10 with Npgsql provider |
| Database | PostgreSQL 16 |
| CQRS / mediator | MediatR 12 |
| Validation | FluentValidation 11 (auto-validation on MVC) |
| Mapping | Riok.Mapperly 4 (compile-time source generator, MIT) |
| Auth | ASP.NET Core Identity + JWT bearer (HMAC-SHA512) + HttpOnly refresh cookie |
| Real-time | SignalR (per-item group chat hub) |
| Photos | Cloudinary |
| SendGrid | |
| Logging | Serilog → compact JSON to stdout |
| Metrics | prometheus-net.AspNetCore at /metrics |
| Health | Microsoft.Extensions.Diagnostics.HealthChecks + AspNetCore.HealthChecks.NpgSql |
| Security headers | NetEscapades.AspNetCore.SecurityHeaders |
| Frontend tooling | Vite 8, ESLint 10 |
| Frontend runtime | React 19, MobX-free (Zustand 5) |
| UI library | Mantine 9 + @tabler/icons-react |
| Forms | @mantine/form + Zod (via mantine-form-zod-resolver) |
| Server state | TanStack Query 5 |
| Routing | React Router 7 |
| HTTP client | axios 1.x with single-flight refresh-token interceptor |
| SignalR client | @microsoft/signalr 10 |
| Container | Multi-stage Dockerfile: node:22-alpine → dotnet/sdk:10.0 → dotnet/aspnet:10.0-noble-chiseled (non-root, no shell) |
| Orchestration | Local: Docker Compose. Cluster: k3s (manifests in Cucox91/cucox-lab-infra) |
.
├── API/ # ASP.NET Core entry point + controllers
│ ├── Controllers/ # Thin: each method is one Mediator.Send
│ ├── Middleware/ # ErrorHandlingMiddleware (RestException → JSON)
│ ├── SignalR/ # ChatHub (per-item live comments)
│ ├── Program.cs # Bootstrap Serilog, host builder, run migrations + seed
│ └── Startup.cs # DI, auth, CSP, health probes, /metrics, routing
│
├── Application/ # MediatR handlers ("vertical slices")
│ ├── Comments/ # one Create handler; SignalR is the only call site
│ ├── Errors/ # RestException
│ ├── Followers/ # Add / Delete / List
│ ├── Interfaces/ # IUserAccessor, IPhotoAccessor, IEmailSender, IJwtGenerator, IProfileReader
│ ├── Items/ # CRUD + photo handlers + DTOs
│ ├── Mapping/ExycleMapper.cs # Mapperly partial class, replaces AutoMapper
│ ├── PhotoService/ # Add/Delete/SetMain for both items and users
│ ├── ProfileService/ # Details / Edit / ListItems + ProfileReader
│ ├── TransactionService/ # Begin / List / Details / AddItem / RemoveItem /
│ │ # Approve / Unapprove + TransactionStatus helper
│ ├── UserService/ # Login / Register / VerifyEmail / RefreshToken / etc.
│ └── Validators/ # Reusable FluentValidation rules (e.g. password complexity)
│
├── Domain/ # POCOs (User, Item, Transaction, ...). No framework deps.
│
├── Infrastructure/ # External services + concrete impls of Application/Interfaces
│ ├── Email/ EmailSender (SendGrid) + SendGridSettings
│ ├── Photos/ PhotoAccessor (Cloudinary) + CloudinarySettings
│ └── Security/ JwtGenerator + UserAccessor
│
├── Persistence/ # EF Core DbContext + migrations + Seed
│ ├── DataContext.cs # IdentityDbContext<User>; relationships configured here
│ ├── Migrations/ # Auto-applied at API startup (Program.cs)
│ └── Seed.cs # Creates the dev test user (see "Seed user" below)
│
├── client-app/ # Vite + React 19 + Mantine SPA
│ ├── src/
│ │ ├── App.tsx # Routes + RequireAuth guard
│ │ ├── main.tsx # MantineProvider, QueryClientProvider, BrowserRouter
│ │ ├── lib/
│ │ │ ├── api.ts # axios instance, JWT interceptor, refresh-on-401
│ │ │ ├── chatHub.ts # useChatHub(itemId) — SignalR hook
│ │ │ ├── itemsApi.ts # typed wrappers for /api/items*
│ │ │ ├── profilesApi.ts # typed wrappers for /api/profiles*
│ │ │ └── transactionsApi.ts # typed wrappers for /api/transactions*
│ │ ├── stores/auth.ts # Zustand auth store (persisted to localStorage)
│ │ ├── types.ts # TypeScript mirrors of all *Dto classes
│ │ └── features/
│ │ ├── auth/ # Login / Register / VerifyEmail
│ │ ├── items/ # List / Details / Form / PhotoUploader / Comments
│ │ ├── layout/ # AppShell (header + navbar)
│ │ ├── profiles/ # Page / Edit / Photos / Items / Followings tabs
│ │ └── transactions/ # List / Details / AddItemDialog
│ ├── vite.config.ts # Dev proxy: /api + /chat → :8080 (ws:true for SignalR)
│ ├── postcss.config.cjs # Mantine PostCSS preset
│ └── package.json
│
├── Dockerfile # Multi-stage SPA + API → chiseled non-root runtime
├── docker-compose.yml # Local dev: api + postgres
├── .env.example # Template; copy to .env and fill in real values
├── Exycle.sln
└── README.md # ← you are here
The complete stack — API, frontend bundle, Postgres — in one container build. Best for "does it work end-to-end" testing.
# 1. Copy the env template, fill in real values (see "Environment variables" below).
cp .env.example .env
$EDITOR .env
# 2. Generate a fresh JWT signing key. Must be ≥ 64 bytes for HMAC-SHA512.
openssl rand -base64 64 # paste into EXYCLE_TOKEN_KEY in .env
# 3. Up.
docker compose up --buildThe API binds http://localhost:8080 and serves both /api/* and the SPA
bundle from wwwroot. Postgres is available on localhost:5432 for inspection
(psql -h localhost -U exycle -d exycle, password exycle-dev-password).
The migrations apply on container start (Program.Main) and Seed.SeedData
populates the test user.
Two terminals:
# Terminal 1 — Postgres only via compose, then API natively for fast .NET iteration.
docker compose up -d postgres
dotnet run --project API# Terminal 2 — Vite dev server with HMR. Hot-reloads on .ts/.tsx edits.
cd client-app
npm install
npm run dev # http://localhost:3000Vite proxies /api/* and /chat (SignalR upgrade) to the API on
:8080. The API's CORS allowlist matches http://localhost:3000.
| Variable | Example | What it's for |
|---|---|---|
EXYCLE_TOKEN_KEY |
output of openssl rand -base64 64 |
JWT signing key. Must be ≥ 64 bytes (HMAC-SHA512) |
EXYCLE_CLOUDINARY_CLOUD_NAME |
your-cloud |
Cloudinary CDN tenant |
EXYCLE_CLOUDINARY_API_KEY |
12345… |
Cloudinary API key |
EXYCLE_CLOUDINARY_API_SECRET |
… |
Cloudinary API secret |
EXYCLE_SENDGRID_USER |
support@example.com |
Verified sender email |
EXYCLE_SENDGRID_KEY |
SG.… |
SendGrid API key |
.env is gitignored. Production secrets on the cluster live in
a SOPS-encrypted Kubernetes Secret.
Persistence/Seed.cs creates this user on first DB run so you can
sign in immediately:
| Username | exycle |
support@exycle.com |
|
| Password | 123**Qwe |
To exercise multi-user flows (transactions, follows, comments) register a second account through the UI. Email verification is on; either click the SendGrid link or skip it via:
docker compose exec postgres psql -U exycle -d exycle \
-c "UPDATE \"AspNetUsers\" SET \"EmailConfirmed\"=true WHERE \"UserName\"='<the-username>';"| Task | Command |
|---|---|
| Rebuild the image and restart | docker compose up --build |
| Reset the database | docker compose down --volumes && docker compose up --build |
| Tail API logs | docker logs -f exycle-api |
| Open psql against the dev DB | docker compose exec postgres psql -U exycle -d exycle |
| Generate an EF migration | dotnet ef migrations add <Name> --project Persistence --startup-project API |
| Apply pending migrations | (automatic at API startup; or dotnet ef database update --project Persistence --startup-project API) |
| Frontend prod build | cd client-app && npm run build (output → dist/, copied into image's wwwroot) |
| Run frontend tests / lint | cd client-app && npm run lint |
┌──────────────────────────────────────────────────────────────────┐
│ API (controllers, middleware, SignalR hub) │
│ ↓ Mediator.Send │
│ Application (handlers, validators, mapper, DTOs, interfaces) │
│ ↓ DbContext / interface impls │
│ Persistence (EF Core DbContext + migrations) Infrastruc- │
│ ↓ Npgsql ture (Cloud- │
│ PostgreSQL inary, Send- │
│ Grid, JWT) │
└──────────────────────────────────────────────────────────────────┘
Each layer references only what it needs; Domain is dependency-free
(plain POCOs, no framework attributes).
- Client sends an HTTP request with
Authorization: Bearer <jwt>(or?access_token=<jwt>for the/chatSignalR path). ErrorHandlingMiddlewarewraps the pipeline;RestExceptionthrown anywhere downstream is converted to a JSON{ errors: ... }with the appropriate status code.NetEscapades.AspNetCore.SecurityHeadersadds CSP, X-Frame-Options, referrer policy, etc.- Authentication middleware validates the JWT.
- Routing dispatches to a controller method. The controller is one
line:
await Mediator.Send(...). - The handler runs in
Application/. Handlers depend only onDataContext,IUserAccessor,IMapper(replaced byExycleMapper), and per-feature interfaces. - EF Core executes against Postgres. Mapperly maps entity → DTO.
- The DTO returns through the controller; Newtonsoft.Json serializes it (camelCase) and the client receives the response.
- Login / register / verify email are anonymous endpoints on
UsersController. - Login returns a
UserDtocontaining a JWT (HMAC-SHA512, 15 min lifetime) and sets an HttpOnly cookie (refreshToken, also 15 min). - Refresh (
POST /api/users/refreshToken) reads the cookie, validates it, and returns a new JWT + rotates the cookie. The axios interceptor on the client side intercepts 401s and calls this endpoint single-flight (multiple parallel 401s share one refresh). - SignalR authenticates via the
access_tokenquery parameter (the WebSocket upgrade can't carry custom headers); the JWT bearer middleware'sOnMessageReceivedhook routes it through.
User : IdentityUser— extends Identity withFirstName,LastName, address fields,MemberSince, navigation collections for items, follows, photos, refresh tokens.Item— title, description, available flag,Userowner,Photos,Comments,TransactionsPool(link table toTransaction).Transaction— two parties (Party,CounterParty),ItemsPool(collection ofTransactionItem),IsApprovedByParty/IsApprovedByCounterParty,Status(new|pending|completed),DateClosed. Source of truth for the lifecycle is the approval booleans +DateClosed;Statusis a denormalized string for display.UserFollowing— many-to-many onUser(Observer ↔ Target). Cascade delete is restricted on both sides to avoid wiping a user when a followed/follower is removed.
API/SignalR/ChatHub.cs exposes:
| Hub method (server) | Direction | Purpose |
|---|---|---|
SendComment(Create.Command) |
client → server | persist + broadcast |
AddToGroup(itemId) |
client → server | subscribe to an item's chat |
RemoveFromGroup(itemId) |
client → server | unsubscribe |
ReceiveComment |
server → client | new comment broadcast |
Send |
server → client | join/leave status string |
The client wrapper is client-app/src/lib/chatHub.ts. Each mounted
ItemDetailsPage opens its own connection, joins the item's group,
and tears down on unmount.
| Path | Component | Purpose |
|---|---|---|
/login, /register, /user/verifyEmail |
auth/* |
unauthenticated |
/ |
ItemsListPage |
grid + search + availability filter + pagination |
/items/new, /items/:id/edit |
ItemFormPage |
shared create/edit form |
/items/:id |
ItemDetailsPage |
hero photo, thumbnails, comments, propose-trade |
/profiles/:username |
ProfilePage |
header + tabs (Items, Photos, Followers, Following) |
/transactions |
TransactionsListPage |
list mine, filter by status |
/transactions/:id |
TransactionDetailsPage |
bidirectional pool editor + approval card |
- Auth — Zustand store, persisted to
localStorage. Only the store touches localStorage; everything else reads fromuseAuthStore. - Server state — TanStack Query handles fetching/caching/invalidation
for every API resource. Default
staleTimeis 30s; we don't retry on 4xx (404/401 etc. are usually permanent). Mutations callqueryClient.setQueryData(...)directly when the server returns the full updated resource (transactions do this), so the page re-renders without a fetch round-trip. - Forms —
@mantine/form+ Zod viamantine-form-zod-resolver. Schemas mirror the server-sideFluentValidationrules.
Dockerfile is multi-stage:
node:22-alpinestage:npm ci, thennpm run buildproduces the SPA bundle in/client/dist.mcr.microsoft.com/dotnet/sdk:10.0stage: restore +dotnet publishproduces the API in/publish.mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseledruntime: no shell, no package manager, runs as$APP_UID(1654) by default. The SPA bundle lands in/app/wwwroot; ASP.NET Core'sUseStaticFiles+UseDefaultFilesserves it. TheFallbackControllerreturns the sameIndexview for any unmatched path so client-side routing works on hard refresh.
Build for both architectures and push:
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/cucox91/exycle:0.1.0 \
--push .Manifests live in Cucox91/cucox-lab-infra:
k8s/apps/exycle/
├── 00-namespace.yaml # PSA `restricted`
├── 10-configmap.yaml # ASPNETCORE_ENVIRONMENT, URLs
├── 15-secrets.yaml # SOPS-encrypted (Cloudinary, SendGrid, JWT, Postgres password)
├── 20-postgres.yaml # StatefulSet + headless Service, 5Gi local-path PVC
├── 30-deployment.yaml # API Deployment, runAsUser 1654, ROFS, drop ALL caps
├── 35-service.yaml # ClusterIP, port 80 → targetPort 8080
├── 36-servicemonitor.yaml # Prometheus scrape (release=kube-prometheus-stack)
└── 40-ingress.yaml # exycle.com + www.exycle.com → exycle-api
Apply order matters (postgres must be Ready before the API tries
to migrate):
kubectl apply -f k8s/apps/exycle/00-namespace.yaml
kubectl apply -f k8s/apps/exycle/10-configmap.yaml
sops --decrypt k8s/apps/exycle/15-secrets.yaml | kubectl apply -f -
kubectl apply -f k8s/apps/exycle/20-postgres.yaml
kubectl -n exycle wait --for=condition=Ready pod/exycle-postgres-0 --timeout=300s
kubectl apply -f k8s/apps/exycle/{30-deployment,35-service,36-servicemonitor,40-ingress}.yaml
kubectl -n exycle rollout status deployment/exycle-apiPublic exposure is via cloudflared
on lab-edge01 → ingress-nginx (MetalLB VIP) → the Service. Cloudflare
DNS points the apex and www at the tunnel.
- ✅ Modernization — netcoreapp3.1 → .NET 10 LTS (every package updated, breaking changes adapted, EF Core 3 → 10).
- ✅ Containerization — multi-stage chiseled Dockerfile, docker-compose for local dev, Postgres as the only database in both dev and prod.
- ✅ Observability — Serilog JSON logs, Prometheus
/metrics, health probes (/healthz/live,/healthz/ready). - ✅ k8s manifests — full set in
cucox-lab-infra. - ✅ Frontend rewrite — CRA 3 / React 16 / MobX 5 / Semantic UI → Vite 8 / React 19 / Zustand 5 / Mantine 9. Auth flow, items (CRUD + photos), profiles (edit + photos + items + followers/following), transactions (full lifecycle: list, details, bidirectional pool editing, approve/unapprove), live SignalR comments.
- ✅ AutoMapper → Mapperly — closes GHSA-rvv3-g6hj-g44x without a commercial license.
- 3.6 Polish
- Per-route code splitting (the bundle is at ~820 KB / 245 KB gzipped; lazy-loading the transactions/profiles routes should cut that 3-4×).
- a11y pass on forms + modals.
- Mobile responsive sweep.
- Backend privacy:
ProfileDtocurrently returnsAddress1/Address2/Zipto anyone; should be owner-only. - DataProtection key persistence (Postgres-backed via
Microsoft.AspNetCore.DataProtection.EntityFrameworkCore) so refresh-token cookies survive pod restarts. - Move off lazy-loading proxies in favor of explicit
.Include(). Cleaner change tracking, fewer N+1 surprises.
- 3.7 Cluster cutover
- Push image to GHCR,
kubectl rollout restartagainst the deployed manifests. - Append
exycle.com/www.exycle.comtocloudflared/config.yaml.tmpland re-deploy the tunnel. - Cloudflare DNS: CNAME apex + www to the tunnel UUID.
- Soak period, then take down the legacy Azure deployment.
- Push image to GHCR,
- Future
- User ratings post-seal (the original product spec — fulfillment tracking + reputation).
- CloudNativePG for HA Postgres + PITR.
- Re-add Facebook (or any external) login via
Microsoft.AspNetCore.Authentication.Facebookif there's demand.
The MediatR vertical-slice pattern keeps changes localized:
- DTO — add to
Application/<Feature>/<Whatever>Dto.cs. - Handler — add
Application/<Feature>/<Action>.cswith nestedCommand/Query,Validator(if applicable), andHandler. - Mapper — add a partial method on
Application/Mapping/ExycleMapper.cs(Mapperly auto-generates the implementation; non-partial methods are picked up by signature for custom mappings). - Controller — one new method on the matching controller; one
line:
return await Mediator.Send(...). - Frontend type — mirror the DTO in
client-app/src/types.ts. - Frontend API helper — add to the matching
lib/<feature>Api.ts. - Frontend page/component — under
client-app/src/features/<feature>/.
For schema changes:
# After modifying entities in Domain/ or relationship config in
# Persistence/DataContext.cs:
dotnet ef migrations add <DescriptiveName> \
--project Persistence --startup-project APIMigrations apply automatically at API startup. The Postgres column types are produced by Npgsql's provider; review the generated SQL for anything non-trivial (especially constraints and indexes).