Patterns and conventions
Project structure conventions
One module per domain
Each feature domain has its own module in each layer:
ocg-server/src/handlers/<domain>.rs— HTTP handlersocg-server/src/db/<domain>.rs— database queriesocg-server/src/types/<domain>.rs— domain types (structs, enums)ocg-server/src/services/<domain>.rs— background services
When a module grows large it is promoted to a directory (e.g., handlers/site/, services/meetings/).
Trait objects for infrastructure
Infrastructure dependencies (database, image storage, notifications, payments, meetings) are accessed through dynamic trait objects rather than concrete types. This pattern allows test doubles and makes dependencies explicit.
// In db.rs
pub(crate) type DynDB = Arc<dyn DB + Send + Sync>;
// In router.rs State
pub db: DynDB,
pub image_storage: DynImageStorage,
pub notifications_manager: DynNotificationsManager,
New infrastructure integrations should follow the same pattern: define a trait, implement it on a concrete struct, expose a Dyn* type alias.
Configuration
Adding a new config key
- Add a field to the appropriate struct in
ocg-server/src/config.rs. - If the field is required, add validation in the struct's
validate()method. - If it is optional and gated behind a feature, wrap it in
Option<T>. - Document the corresponding
OCG_*env var name in the field's doc comment. Nested structs use__as separator (e.g.,OCG_MEETINGS__ZOOM__ENABLED).
Config is loaded in Config::new() using Figment — no registration step is needed.
Environment variable naming
All env vars use the OCG_ prefix. Nested config keys map to __-separated env var names. Examples:
| Config path | Env var |
|---|---|
server.addr |
OCG_SERVER__ADDR |
meetings.zoom.enabled |
OCG_MEETINGS__ZOOM__ENABLED |
integrations.you_com.api_key |
OCG_INTEGRATIONS__YOU_COM__API_KEY |
HTTP handlers
Handler signature
Handlers are plain async functions. Dependencies are extracted from the axum State using FromRef. Do not accept the full State struct unless necessary — extract only what the handler needs:
pub(crate) async fn page(
AxumState(db): AxumState<DynDB>,
AxumState(server_cfg): AxumState<HttpServerConfig>,
Path(alliance): Path<String>,
) -> impl IntoResponse {
// ...
}
HTMX fragments vs full pages
Check the hx-request or x-ocg-fetch header to decide whether to render a partial fragment or a full HTML page. The refresh_stale_clients middleware in ocg-server/src/router.rs handles stale-client detection automatically — handlers do not need to inspect the commit SHA header themselves.
Input validation
Input structs are validated with garde. Annotate fields with #[garde(...)] rules and call .validate() before passing to the database layer. Common validators are centralised in ocg-server/src/validation.rs.
Error handling
Handlers return impl IntoResponse. Use StatusCode for simple errors. For user-visible errors, render a template with an error message. Avoid unwrap() in handler code; use ? or explicit error handling.
Database layer
Query placement
SQL queries belong in ocg-server/src/db/<domain>.rs as methods on PgDB. Do not embed SQL in handlers or services. Method names should describe what is returned or mutated (e.g., get_group_by_slug, add_event_attendee).
Prepared statements
Use deadpool-postgres prepared statements via client.prepare_cached(). This avoids repeated parse-and-plan overhead on repeated queries.
Contract tests
Database contract tests live in ocg-server/src/db/<domain>.rs under #[cfg(test)] blocks, gated with #[ignore] so they only run when explicitly invoked (just db-contract-tests). They require the contract test database (ocg_tests_contract) to be seeded with database/tests/data/contract.sql.
Database migrations
- Migration files go in
database/migrations/schema/. - Files are numbered sequentially:
NNNN_description.sql. - Each file should be self-contained and idempotent where possible.
- Run
just db-migrateto apply pending migrations to the main database. - Add a corresponding seed row to
database/tests/data/contract.sqlif the migration adds reference data.
Services (background workers)
Background services are started in ocg-server/src/main.rs and run on separate Tokio tasks. Each service module exposes a run() or similar entry point. Use tokio::time::interval for periodic tasks and rely on the DynDB handle (already connection-pooled) rather than creating ad-hoc connections.
Rust style
- Run
cargo fmtbefore committing. Thejust server-fmt-and-linttask runscargo fmt,cargo check, andcargo clippy --deny warnings. - All
clippywarnings are treated as errors in CI. - Prefer
pub(crate)overpubfor items that are not part of a public API. - Use
#[instrument]fromtracingon public handler and service functions for automatic span creation. - Doc comments (
///) are expected on public andpub(crate)items.
Frontend
- JavaScript files live in
ocg-server/static/js/. Format withprettierusing the config atocg-server/static/js/.prettierrc.yaml. - HTML templates live in
ocg-server/templates/. Lint withdjlintusing the config atocg-server/templates/.djlintrc. - Run
just frontend-fmt-and-lintto check both.
Tests
| Test type | Location | Run with |
|---|---|---|
| Rust unit | ocg-server/src/ (#[test]) |
just server-tests |
| DB contract | ocg-server/src/db/ (#[ignore]) |
just db-contract-tests |
| DB pgTAP | database/tests/ |
just db-tests |
| Frontend unit | tests/unit/ |
npm --prefix tests/unit test |
| E2E (Playwright) | tests/e2e/ |
just e2e-tests |
Visual snapshot tests are tagged @visual and updated with just e2e-update-snapshots.