345 lines
16 KiB
Markdown
345 lines
16 KiB
Markdown
# Project Architecture Preferences
|
|
|
|
Baseline architectural conventions for new projects. Claude Code should follow these defaults when scaffolding, implementing, or refactoring unless the project explicitly overrides them. When in doubt, ask before deviating.
|
|
|
|
These preferences are opinionated and have evolved from running real multi-site infrastructure. They optimise for: local-first operation, reproducible deployments, minimal release-time churn, and keeping secrets out of source control.
|
|
|
|
---
|
|
|
|
## 1. Workspace Layout
|
|
|
|
Projects are Rust cargo workspaces. The repository root contains:
|
|
|
|
```
|
|
<repo-root>/
|
|
├── Cargo.toml # workspace manifest (workspace-level deps + version)
|
|
├── crates/ # all Rust crates live here
|
|
│ ├── <app>-entities/ # domain types, DTOs, error enums — no I/O
|
|
│ ├── <app>-core/ # business logic, pure where practical
|
|
│ ├── <app>-data/ # data access: postgres, turso, filesystem, etc.
|
|
│ ├── <app>-crypto/ # shared cryptography (only if needed across bins)
|
|
│ ├── <app>-os-utils/ # shared OS/process/path helpers (only if needed)
|
|
│ ├── <app>-api/ # binary: REST / JSON / WebSocket daemon
|
|
│ ├── <app>-worker/ # binary: long-running processor / queue consumer
|
|
│ └── <app>-cli/ # binary: operator / admin CLI
|
|
├── <web/dashboard/ui>/ # Vite + React + SWC + TS frontend (when applicable)
|
|
├── asset/ # deployment artifacts (see §6)
|
|
├── script/ # deploy.sh and related operational scripts
|
|
└── README.md
|
|
```
|
|
|
|
### Crate naming
|
|
Use `<app>-<role>` throughout. The `<app>` prefix makes grep, cargo output, and systemd unit naming unambiguous across a multi-project host.
|
|
|
|
### Separation of concerns (strict)
|
|
- **entities** — types only. No I/O, no async runtime deps. Serde, thiserror, chrono/time are fine. Everything downstream depends on this.
|
|
- **core** — business logic. Consumes entities. May define traits for data access (ports) that the `data` crate implements (adapters). No direct DB or network calls.
|
|
- **data** — implements the traits defined in `core`. Owns all sqlx/turso/reqwest usage relevant to persistence and external services.
|
|
- **binaries** — thin. Wire up config, logging, signal handling, and the appropriate core/data stack. Binaries should contain no business logic that could live in a library crate.
|
|
|
|
### Shared utility crates
|
|
Only create `<app>-crypto`, `<app>-os-utils`, etc. when genuinely shared by two or more binaries. Premature extraction is worse than inlining — extract when the second consumer appears.
|
|
|
|
---
|
|
|
|
## 2. Cargo Workspace Conventions
|
|
|
|
### Workspace-level versioning
|
|
Every crate in the workspace shares a single version, defined once in the root `Cargo.toml`:
|
|
|
|
```toml
|
|
[workspace.package]
|
|
version = "0.1.0"
|
|
edition = "2024"
|
|
rust-version = "1.85"
|
|
license = "GPL-3.0-or-later" # adjust per project
|
|
authors = ["Rob Thijssen <rob@example>"]
|
|
|
|
[workspace]
|
|
resolver = "3"
|
|
members = ["crates/*"]
|
|
```
|
|
|
|
Each crate's `Cargo.toml` inherits:
|
|
|
|
```toml
|
|
[package]
|
|
name = "<app>-entities"
|
|
version.workspace = true
|
|
edition.workspace = true
|
|
rust-version.workspace = true
|
|
license.workspace = true
|
|
authors.workspace = true
|
|
```
|
|
|
|
**Rationale:** release tagging scripts only need to rewrite one version string. CI stamps and changelogs stay consistent across artifacts from the same tag.
|
|
|
|
### Workspace-level dependencies
|
|
Declare every external dependency once under `[workspace.dependencies]` in the root manifest. Crates reference them via `dep.workspace = true`. This prevents version drift between crates in the same workspace.
|
|
|
|
```toml
|
|
[workspace.dependencies]
|
|
tokio = { version = "1", features = ["full"] }
|
|
serde = { version = "1", features = ["derive"] }
|
|
sqlx = { version = "0.8", default-features = false, features = ["postgres", "runtime-tokio-rustls", "macros", "migrate"] }
|
|
thiserror = "2"
|
|
tracing = "0.1"
|
|
# ...
|
|
```
|
|
|
|
### Internal crate dependencies
|
|
Internal crates depend on each other via path + version:
|
|
|
|
```toml
|
|
<app>-entities = { path = "../<app>-entities", version = "=0.1.0" }
|
|
```
|
|
|
|
Use `=` pinning on the internal version to guarantee in-workspace coherence after publishing or vendoring.
|
|
|
|
### Edition and toolchain
|
|
- Rust edition **2024**.
|
|
- Commit a `rust-toolchain.toml` at the repo root pinning the stable channel to match CI.
|
|
|
|
---
|
|
|
|
## 3. Binaries and Runtime
|
|
|
|
### Daemons run under systemd
|
|
API and worker binaries are managed by systemd unit files shipped from `asset/systemd/`. Binaries should:
|
|
|
|
- Log to stdout/stderr using `tracing` with structured JSON output when `JOURNAL_STREAM` is set (journald will ingest cleanly).
|
|
- Read config from `/etc/<app>/config.toml` by default, overridable via `--config` and env vars (figment-style layering: file → env → CLI).
|
|
- Handle `SIGTERM` gracefully: stop accepting new work, drain in-flight tasks with a bounded timeout, exit 0.
|
|
- Never daemonise themselves. systemd owns the lifecycle.
|
|
- Expose a health endpoint (for api) or emit periodic heartbeat logs (for workers) so `systemd` and monitoring can assess liveness.
|
|
|
|
### API crate
|
|
- Axum is the default web framework unless there's a reason otherwise.
|
|
- Serves REST + JSON over TCP, with WebSocket upgrades where streaming is needed.
|
|
- TLS terminates at the site nginx reverse proxy (see §7) unless the binary itself is the ingress (e.g., Cichlid-style self-serving nodes), in which case use rustls with post-quantum-capable cipher suites.
|
|
- API surface versioned under `/v1/` from day one.
|
|
- Request/response types live in `<app>-entities` so clients (including the desktop app) can depend on them.
|
|
|
|
### Worker binaries
|
|
- Long-running processors (ingestion, indexing, queue consumers) use Postgres `FOR UPDATE SKIP LOCKED` for work-claiming where a central DB is already in play.
|
|
- Idempotent by design — crashes and restarts should never double-process.
|
|
- Backoff with jitter on transient failures; escalate to DLQ semantics on repeated failure.
|
|
|
|
---
|
|
|
|
## 4. Frontends
|
|
|
|
### Web (default)
|
|
Vite + React + SWC + TypeScript, in `<repo-root>/web/`:
|
|
|
|
```
|
|
web/
|
|
├── package.json
|
|
├── vite.config.ts
|
|
├── tsconfig.json
|
|
├── index.html
|
|
└── src/
|
|
├── main.tsx
|
|
├── App.tsx
|
|
├── api/ # generated or hand-written client for the <app>-api
|
|
├── components/
|
|
├── routes/
|
|
└── lib/
|
|
```
|
|
|
|
- Build output is static. Deployed to an nginx CDN endpoint — no Node.js in production.
|
|
- API base URL is configured at build time (Vite `import.meta.env.VITE_API_BASE_URL`) and stamped per environment during deploy.
|
|
- Prefer React Query or equivalent for server state. Keep business logic server-side; the frontend is a rendering and interaction layer.
|
|
|
|
### Web (Rust framework exception)
|
|
Use a Rust web framework (Axum + templating, or a fullstack framework) **only when** the deployment model requires a single self-contained binary with no external web server — e.g., distributed orchestration nodes that each serve their own UI over TLS. The Cichlid pattern. Default is still Vite + nginx.
|
|
|
|
### Desktop
|
|
Tauri. Consumes the same `<app>-api` as the web client. Shares types via the `<app>-entities` crate (exposed to the Tauri frontend via generated TypeScript bindings — `ts-rs` or `specta`).
|
|
|
|
### Mobile
|
|
**Preferences TBD.** When a project targets mobile, the goal is a framework that consumes the existing backend API (keeping business logic server-side) and produces responsive native-quality UIs for both Android and iOS from a shared codebase. Revisit this section once there's real-world experience to draw on.
|
|
|
|
---
|
|
|
|
## 5. Data
|
|
|
|
### Central database: Postgres
|
|
Default for any app with a central data store.
|
|
|
|
- Connection is **mTLS with passwordless auth**. Host-level client certificates issued by the internal step-ca, with cert CN → pg role mapping via `pg_ident.conf`.
|
|
- No passwords in config files, ever. Connection strings reference cert paths.
|
|
- Migrations via `sqlx-cli` or `refinery`; migration files live in `crates/<app>-data/migrations/`.
|
|
- Schema changes are forward-only in production. Destructive migrations require a dedicated maintenance window and an explicit plan.
|
|
- Use `sqlx` with compile-time query checking (`sqlx prepare`) and commit the generated `.sqlx/` offline query cache so CI builds don't need a live database.
|
|
|
|
### Distributed database: Turso
|
|
When the app's data model is distributed (edge replicas, per-site local copies with sync), use Turso. Auth via Turso-issued tokens stored in the per-host secret store, not in `manifest.yml`.
|
|
|
|
### Caching / ephemeral state
|
|
Prefer in-process (moka, quick-cache) over introducing Redis. Only add Redis when multiple processes genuinely need to share ephemeral state and Postgres LISTEN/NOTIFY won't do.
|
|
|
|
---
|
|
|
|
## 6. Deployment Assets (`asset/`)
|
|
|
|
`asset/` is the single source of truth for what gets deployed where. **No secrets in this directory, ever** — it's in source control.
|
|
|
|
```
|
|
asset/
|
|
├── manifest.yml # environments → components → hosts
|
|
├── systemd/
|
|
│ ├── <app>-api.service
|
|
│ ├── <app>-api.socket # if socket-activated
|
|
│ ├── <app>-worker.service
|
|
│ ├── <app>-indexer.timer
|
|
│ └── <app>-indexer.service
|
|
├── nginx/
|
|
│ └── <app>.<site>.conf # per server_name configs
|
|
├── config/
|
|
│ ├── config.toml.tmpl # templated; {{SECRET_NAME}} placeholders
|
|
│ └── ...
|
|
└── sql/
|
|
└── bootstrap.sql # idempotent role/db creation
|
|
```
|
|
|
|
### `manifest.yml` structure
|
|
|
|
```yaml
|
|
app: <app>
|
|
environments:
|
|
prod:
|
|
components:
|
|
api:
|
|
hosts: [oolon.hanzalova.internal]
|
|
config:
|
|
bind: 0.0.0.0:8080
|
|
log_level: info
|
|
worker:
|
|
hosts: [gramathea.kosherinata.internal, oolon.hanzalova.internal]
|
|
config:
|
|
concurrency: 4
|
|
web:
|
|
hosts: [cdn.hanzalova.internal]
|
|
root: /var/www/<app>
|
|
dev:
|
|
components:
|
|
api:
|
|
hosts: [quadbrat.hanzalova.internal]
|
|
config:
|
|
bind: 0.0.0.0:8080
|
|
log_level: debug
|
|
# ...
|
|
```
|
|
|
|
Top-level keys: `app`, `environments`. Each environment defines `components`, each component defines `hosts` (one or many) and `config` (non-secret values only). Secret references are placeholders resolved by `deploy.sh` at deploy time.
|
|
|
|
### Templated config
|
|
Config file templates use a simple `{{VAR_NAME}}` syntax. `deploy.sh` substitutes values from the host's `pass` store (or equivalent) into the template before shipping to the target. The unrendered template is committed; the rendered file never is.
|
|
|
|
---
|
|
|
|
## 7. Deployment Script (`script/deploy.sh`)
|
|
|
|
A bash script with a stable CLI:
|
|
|
|
```
|
|
./script/deploy.sh <environment> [component...]
|
|
|
|
./script/deploy.sh prod api worker
|
|
./script/deploy.sh dev all
|
|
./script/deploy.sh prod default
|
|
```
|
|
|
|
### Contract
|
|
- First positional arg is the environment name, matched against `manifest.yml` `environments.*`.
|
|
- Subsequent args name components, or `all` (every component in the environment), or `default` (a project-defined sensible subset — often everything except disruptive migrations).
|
|
- Parses `manifest.yml` with `yq`.
|
|
- For each `(component, host)` pair:
|
|
1. Build the artifact locally (cargo build release, vite build, etc.) if not already built for the current commit.
|
|
2. Resolve secrets from `pass` (or the project's configured secret backend) and render config templates.
|
|
3. `rsync` binary + rendered config + systemd units to the target host over ssh (quantum-safe key exchange).
|
|
4. `ssh` to the target to `systemctl daemon-reload` and restart the unit(s).
|
|
5. Verify health (HTTP probe for api, `systemctl is-active` for all).
|
|
- Exit non-zero on any failure. Report per-host status at the end.
|
|
|
|
### Requirements
|
|
- Idempotent. Running twice with no changes is a no-op beyond file copies.
|
|
- Quiet on success, loud on failure.
|
|
- Supports `--dry-run` to print what would happen.
|
|
- Never writes secrets to disk on the build host outside of the rendered template being rsynced.
|
|
|
|
---
|
|
|
|
## 8. Infrastructure Context
|
|
|
|
This is the environment these apps deploy into. Implementation should assume it.
|
|
|
|
### Network
|
|
- Multi-site WireGuard mesh. Sites are numbered; host IPs follow `10.<site>.0.0/16` (currently `10.3.0.0/16` and `10.6.0.0/16`, but the second octet encodes the site and is the stable part).
|
|
- Per-site OPNsense router handles WAN/LAN and the WireGuard endpoints.
|
|
- Internal DNS split-horizon via `.internal` domains (`hanzalova.internal`, `kosherinata.internal`, etc.).
|
|
|
|
### TLS / PKI
|
|
- Internal PKI via Smallstep `step-ca` at `ca.internal`.
|
|
- Host certs renewed via systemd timers.
|
|
- mTLS everywhere internal services talk to each other.
|
|
- **Quantum-safe** SSH (sntrup761x25519 KEX) and TLS (X25519MLKEM768 where peers support it) are the default. External peers that don't support PQ fall back to classical curves — document the fallback explicitly in nginx config.
|
|
|
|
### Ingress
|
|
- Per-site nginx reverse proxy terminates all WAN inbound 443.
|
|
- Public DNS via Cloudflare, **unproxied by default** (CF's mTLS origin-pull has been unreliable). Revisit if/when that changes.
|
|
- nginx serves static frontends directly from `/var/www/<app>` and reverse-proxies API traffic to the internal host:port from `manifest.yml`.
|
|
|
|
### Hosts
|
|
- Workstations and servers run the latest Fedora.
|
|
- Services run as dedicated non-root users created by RPM scaffolding or `sysusers.d` drop-ins shipped in `asset/systemd/`.
|
|
- SELinux enforcing. Any non-standard file paths or ports require a policy module shipped with the app.
|
|
- Podman quadlets for containerised workloads; bare-metal systemd units for native Rust binaries (preferred where feasible).
|
|
|
|
---
|
|
|
|
## 9. Code Quality and Tooling
|
|
|
|
### Formatting and linting
|
|
- `cargo fmt` on commit (pre-commit hook or CI gate).
|
|
- `cargo clippy --all-targets --all-features -- -D warnings` must pass.
|
|
- Frontend: `eslint` + `prettier`, configured to match the team style. Type errors fail the build.
|
|
|
|
### Testing
|
|
- Unit tests live alongside the code they test (`#[cfg(test)] mod tests`).
|
|
- Integration tests under `crates/<crate>/tests/`.
|
|
- End-to-end tests that require a database use a dedicated test DB per run, created and torn down by the test harness.
|
|
- Target: core business logic has meaningful test coverage. Binaries have smoke tests.
|
|
|
|
### Observability
|
|
- `tracing` with `tracing-subscriber`. JSON output in production, pretty output when stdout is a TTY.
|
|
- Structured log fields for request IDs, user IDs (where applicable), and operation names.
|
|
- No `println!` or `eprintln!` in committed code outside of CLI binaries' user-facing output.
|
|
|
|
### Error handling
|
|
- Library crates use `thiserror` for typed errors.
|
|
- Binaries use `anyhow` at the outermost layer, with `.context(...)` at call boundaries.
|
|
- Never `unwrap()` in production code paths. `expect("...")` with a clear message is acceptable for invariants that are genuinely impossible to violate.
|
|
|
|
### Documentation
|
|
- Every public item in library crates has a doc comment.
|
|
- Each crate has a `README.md` or top-level module doc explaining its role in the workspace.
|
|
- The repo `README.md` covers: what the project does, how to build, how to run locally, how to deploy. Point readers to this document for architectural conventions.
|
|
|
|
---
|
|
|
|
## 10. Conventions Summary for Scaffolding and Implementation
|
|
|
|
When scaffolding or extending a project:
|
|
|
|
1. Default to the workspace layout in §1. Ask before deviating.
|
|
2. Put new types in `entities`, new logic in `core`, new I/O in `data`. Binaries stay thin.
|
|
3. Add dependencies to the workspace root first, then reference with `dep.workspace = true`.
|
|
4. Version strings live in exactly one place — the workspace root.
|
|
5. Any new deployable component gets an entry in `asset/manifest.yml` and a systemd unit in `asset/systemd/` in the same change.
|
|
6. Config templates go in `asset/config/` with `{{PLACEHOLDER}}` secrets. Never commit a rendered config.
|
|
7. Postgres connections are mTLS, passwordless. If writing connection code that accepts a password, stop and ask.
|
|
8. Frontend is Vite + React + SWC + TS, served as static assets from nginx. Rust web frameworks require a stated reason.
|
|
9. Prefer fewer dependencies. Prefer bare-metal systemd over containers unless there's a reason.
|
|
10. When unsure, ask — these preferences are defaults, not mandates, but deviations should be deliberate. |