Files
architecture/generic.md
2026-04-22 11:45:00 +03:00

16 KiB

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:

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

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

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

<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

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.