Opinionated Rust microservice template — axum (HTTP) + tonic (gRPC) + sqlx (PostgreSQL) +
redis (Valkey) + tracing/opentelemetry, organized as clean architecture (DDD) per feature:
contract → domain → port → application → adapter/{driving,driven} with per-feature di
wiring, a decoupled platform shell, and a published inbound contract facade (crate::api::v1).
All dependencies point inward, and the rule is enforced executably in CI.
- Rust stable (toolchain pinned via
rust-toolchain.toml; edition 2024) - protoc 3.15+ (for the proto compile in
build.rs— Rust gRPC generation) - PostgreSQL 18+ and Valkey 9+ (or use
compose.yml) - Docker / Podman for local infra (optional —
cargoworks directly against the host) - Task (
go-task) — optional wrapper aroundcargocommands; install from https://taskfile.dev. If absent, run thecargocommands directly.
# 1. Start local infra (postgres + valkey; add `observability` profile for OTel + Prometheus + Grafana)
docker compose up -d postgres valkey
# 2. Apply migrations via the `migrate` binary (reads DATABASE_URL or DB_* env vars)
cargo run --bin migrate -- up
# or, with task installed:
task migrate-up
# 3. Run the server
cargo run --bin server
# or, with task installed:
task runcargo run --bin server reads config.yaml from the working directory and overlays environment
variables using the explicit leaf-binding table — env names match the Go template exactly
(APP_NAME, DB_HOST, VALKEY_PASSWORD, …).
For the full containerised stack (migrations as a one-shot migrate service, then server plus
optional observability profile):
docker compose up -d # postgres + valkey + migrate + server
docker compose --profile observability up -d # + otel-collector, prometheus, grafanaThe migrate binary supports the standard sqlx-style subcommands:
| Subcommand | Description |
|---|---|
up |
Apply all pending migrations. |
down [N] |
Roll back the most recent N migrations (default 1). |
force VERSION |
Force-set the migration version table (recovery / repair). |
version |
Print the current applied migration version. |
zercle-rust-template/
├── .agents/ # harness state (conductor-owned)
├── .github/
│ ├── dependabot.yml # weekly cargo + actions + docker updates
│ └── workflows/ci.yml # fmt → clippy → architecture → unit → integration → build
├── proto/
│ └── example/v1/example.proto # example feature gRPC contract
├── migrations/
│ ├── 000001_create_items_table.up.sql
│ └── 000001_create_items_table.down.sql
├── src/
│ ├── main.rs # bin `server`: load config → lib::run
│ ├── lib.rs # crate root + module declarations
│ ├── bin/migrate.rs # bin `migrate`: up / down [N] / force / version
│ ├── app.rs # composition root: platform + features::example::di
│ ├── api/
│ │ ├── mod.rs # published facade namespace (outward-only)
│ │ └── v1.rs # re-exports contract types + errcodes (Go pkg/api/v1)
│ ├── platform/ # cross-cutting shell — never imports features
│ │ ├── config.rs # Config + Load + Validate (decision D5)
│ │ ├── db.rs # PgPool + ping + readiness checker
│ │ ├── valkey.rs # redis client + ping + readiness checker
│ │ ├── errors.rs # AppError + errcodes + IntoResponse + tonic mapper
│ │ ├── health.rs # Checker trait + Registry
│ │ ├── telemetry.rs # tracing + OTel + Prometheus init
│ │ ├── middleware/
│ │ │ ├── request_id.rs # X-Request-ID propagate / generate
│ │ │ ├── access_log.rs # one structured access log per request
│ │ │ ├── recover.rs # panic → 500
│ │ │ └── cors.rs # tower-http CORS from config
│ │ └── server/
│ │ ├── mod.rs # AppState + run() + grpc_server() builder
│ │ ├── http.rs # axum router, middleware stack, shared routes
│ │ ├── grpc_interceptor.rs # tonic unary interceptor (request_id + access log)
│ │ └── shutdown.rs # ordered graceful shutdown
│ └── features/
│ └── example/ # STUB FEATURE — delete to start your project
│ ├── contract/ # inbound wire types (LEAF; published via crate::api::v1)
│ │ ├── create_item.rs # CreateItemRequest + ItemResponse
│ │ └── list_items.rs # ListItemsRequest + ListItemsResponse
│ ├── domain/ # innermost layer (no crate deps)
│ │ ├── item.rs # Item entity
│ │ └── error.rs # domain sentinel errors
│ ├── port/
│ │ └── repository.rs # outbound Repository trait (mockall)
│ ├── application/
│ │ ├── service.rs # inbound Service trait — speaks contract types
│ │ └── usecase.rs # Usecase impl: domain ↔ contract mapping
│ ├── adapter/
│ │ ├── driving/ # driving adapters (call application::Service)
│ │ │ ├── http.rs # axum handlers
│ │ │ └── grpc.rs # tonic ExampleService server
│ │ └── driven/
│ │ └── postgres.rs # sqlx impl of port::Repository
│ ├── di.rs # wiring + sentinel→AppError registration
│ └── mod.rs # layer map + delete-me notice
├── tests/
│ ├── common/mod.rs # shared helpers for integration + e2e tests
│ ├── architecture.rs # executable dependency gates (layering rules)
│ ├── example_http.rs # integration: feature flows via di (self-skips w/o infra)
│ └── e2e.rs # e2e: boots the full app (self-skips w/o infra)
├── build.rs # tonic-build: compile proto/example/v1/example.proto
├── Cargo.toml # crate manifest
├── Cargo.lock # committed for reproducible builds
├── rust-toolchain.toml # pinned stable + rustfmt + clippy
├── rustfmt.toml
├── Taskfile.yml # cargo wrapper (build, test, migrate, docker-build, cover)
├── Containerfile # multi-stage musl + distroless (server image)
├── Containerfile.migrate # multi-stage migrate image
├── compose.yml # postgres + valkey + migrate + server + observability profile
├── config.yaml # server config (same keys as Go template)
├── .env.example
├── deployments/
│ ├── kustomize/{base,overlays/development}/
│ └── observability/{otel-collector-config.yaml,prometheus.yml}
└── LICENSE
Clean architecture (DDD) per feature, mirroring zercle-go-template's
internal/features/<name>/{contract,domain,port,application,adapter,di}. All dependencies
point inward — and the rule is executable, not aspirational: tests/architecture.rs
(the CI Architecture job) scans every use crate::… statement and fails on any
layer violation.
contract/— the exposed inbound type contract (leaf): canonical request/response wire types (serde+validatoronly). Both driving adapters bind these directly; the published facadecrate::api::v1re-exports them (pluserrcodes) so other services can construct payloads without importing server internals. Internal code may never importcrate::api.domain/— innermost: entities + sentinel errors, no crate-internal dependencies.port/— outbound (driven) ports: theRepositorytrait; references only its own domain.application/— use cases: the inboundServiceport speaks contract types at the boundary (create(CreateItemRequest) -> ItemResponse), so driving adapters never map to or from domain entities. TheUsecaseimpl owns the domain ↔ contract mapping and the single validation path (e.g. wire-id parsing) shared by HTTP and gRPC.adapter/driving/(axum handlers, tonic server): translate transport ↔ contract, callapplication::Service.adapter/driven/(sqlx): implementsport::Repository— (inis a Rust keyword, hence the hexagonaldriving/drivennames).di.rs— composition edge: wires repository → use case → adapters, nests HTTP routes under/api/v1, registers the feature's gRPC service on the platform's tonic builder, and registers thedomain::Error → AppErrorsentinel mapping (GoRegisterSentinelparity).platform/— cross-cutting shell, feature-agnostic by rule (platform-ignores-featuresgate): config, db, valkey, boundary errors (AppError+errcodes), health, telemetry, middleware, and the HTTP/gRPC server shell. Feature routers arrive pre-mounted; the shell adds shared routes (/healthz,/readyz,/metrics) and the middleware stack.app.rs— composition root: builds platform in dependency order (telemetry → postgres → valkey → health) and calls each feature'sdi::register— the only feature symbol the shell references. Adding a feature = adding onedi::registercall.- Trait-based ports + mockall mocks:
#[cfg_attr(test, automock)]generatesMockRepository/MockService; use-case and adapter unit tests need no real DB. - gRPC unary interceptor (
src/platform/server/grpc_interceptor.rs) mirrors the HTTP middleware stack's panic-recovery + access-log guarantees — it recovers handler panics intotonic::Status::internaland emits one structured access log per unary call. OTel tracing for gRPC (including streams) is provided byServer::trace_fn. - Typed errors: each feature's
domain::Errorsentinels map to the sharedAppErrorat thedicomposition edge;AppErrormaps to bothStatusCode(axum) andtonic::Codeat the boundary — no string matching. - Env binding = explicit leaf table (decision D5): the
configcrate's default_separator would collide with SCREAMING_SNAKE names, so we port the GoleafBindings()table verbatim and override each leaf fromstd::envafter loading the yaml. - Graceful shutdown: SIGTERM/SIGINT triggers
axum::serve(...).with_graceful_shutdown→ tonicRouter::serve_with_shutdowndrain →PgPool::close→ConnectionManagerdrop → OTel provider flush, all bounded bycfg.app.shutdown_timeout.
src/features/example/ ships with //! STUB FEATURE — delete src/features/example to start your project. headers — it's a worked example of the clean-architecture layout. To start a
real project:
rm -rf src/features/exampleThen:
- Drop
pub mod example;fromsrc/features/mod.rs(or keepfeaturesand add your own sub-module). - Remove the
example::di::registercall fromsrc/app.rsand theexamplesection fromconfig.yaml/Config(src/platform/config.rs). - Remove the published facade re-exports in
src/api/v1.rs(or point them at your new feature'scontractmodule) and droptonic-buildfromCargo.tomlif you don't need gRPC.
The platform, api facade pattern, and app shell do not reference the feature from anywhere
else — that is what the architecture test enforces.
# Unit tests (no infra needed)
cargo test --all-targets
# or: task test-unit
# Clean-architecture dependency gates (layering rules; no infra needed)
cargo test --test architecture
# or: task test-architecture
# Full suite against live postgres + valkey (skips cleanly if unreachable)
docker compose up -d postgres valkey
cargo test --all-targets -- --include-ignored --test-threads=1
# or: task test-integration
# End-to-end test (boots the full app; needs infra + migrations applied)
task migrate-up
cargo test --test e2e
# or: task test-e2eThe unit suite covers config (yaml parse + validate), errors (status / code mapping),
health (registry semantics), contract (wire shapes + validation), application
(use-case rules via mocks), and both driving adapters. tests/architecture.rs enforces the
layering. The integration (tests/example_http.rs) and e2e (tests/e2e.rs) tests skip
cleanly when the relevant infrastructure is unreachable, so a partial local setup never
breaks cargo test.
Other quality gates:
cargo clippy --all-targets -- -D warnings # or: task lint
cargo fmt --all -- --check # or: task fmt-check
cargo llvm-cov --workspace --all-targets # or: task cover (requires cargo-llvm-cov).github/workflows/ci.yml runs on every push/PR to main/develop:
- fmt —
cargo fmt --all -- --check. - clippy —
cargo clippy --all-targets --locked -- -D warnings. - architecture — the layering gates from
tests/architecture.rs(fast, dedicated signal). - unit — full test run with
cargo-llvm-covcoverage; lcov + HTML artifacts uploaded, gated at 60% line coverage. - integration — full suite (
--include-ignored) against realpostgres:18-alpine+valkey:9-alpineservice containers, migrations applied via themigratebinary. - build — release build of both binaries (version metadata injected) + a
docker buildof the Containerfile.
All actions are pinned to commit SHAs and checkouts run with persist-credentials: false;
Dependabot keeps the pins, crates, and base images updated weekly.
- Local containers:
docker compose up -d(add--profile observabilityfor OTel + Prometheus + Grafana). The compose stack runs themigrateservice once before theserverservice starts. - Kubernetes:
kubectl apply -k deployments/kustomize/overlays/development. The baseDeploymentruns the distroless image as non-root withreadOnlyRootFilesystem: true; secrets holdDB_PASSWORD/VALKEY_PASSWORD. - Container build:
- Server:
task docker-build(docker build -f Containerfile -t zercle-rust-template:latest .) - Migrate:
task docker-build-migrate(docker build -f Containerfile.migrate …) Both are multi-stage (rust:slimbuilder → distroless/static non-root final).
- Server:
- All env var names are identical (
APP_NAME,DB_HOST,OTEL_TRACES_SAMPLER_ARG, …) — point your existing config maps / secrets at the new workload without changes. - All config keys are identical —
config.yamlround-trips. - gRPC service definitions are byte-faithful (
proto/example/v1/example.proto); existing protobuf clients work unchanged. - Health endpoints (
/healthz,/readyz) and metrics path (/metrics) match.
MIT — see LICENSE.