A set of reusable Rust library crates for building modular web applications. Provides composable, type-safe building blocks for dependency injection, authentication, HTTP routing, and database access.
Apps depend on these crates and implement their own domain/services/infrastructure layers.
Your Application (domain, services, infrastructure, http handlers)
↓
stano-launcher (router wiring, middleware, auth, graceful shutdown)
↓
┌────────────────────────────────────────────────────┐
│ stano-di (IoC) stano-axum (HTTP) stano-seaorm │
│ stano-security (JWT) stano-common (errors, IDs) │
│ stano-di-macros │
└────────────────────────────────────────────────────┘
Dependency flow: App → launcher → platform crates. Domain has zero external deps.
Create a minimal app:
[dependencies]
stano-di = { git = "...", package = "stano-di" }
stano-launcher = { git = "...", package = "stano-launcher" }
stano-security = { git = "...", package = "stano-security" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
axum = "0.8"Create .env:
JWT_PRIVATE_KEY="-----BEGIN EC PRIVATE KEY-----\n...\n-----END EC PRIVATE KEY-----"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
PORT=3000Generate keys:
openssl ecparam -name prime256v1 -genkey -noout -out private.pem
openssl ec -in private.pem -pubout -out public.pemuse stano_di::{application_context::ApplicationContext, environment::OsEnvironment};
use stano_launcher::{get, BootstrapConfig, run};
use stano_security::JwtConfig;
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let env = Arc::new(OsEnvironment::new());
let config = BootstrapConfig {
port: env.get("PORT").and_then(|p| p.parse().ok()).unwrap_or(3000),
jwt_config: JwtConfig {
private_key_pem: env.get("JWT_PRIVATE_KEY").expect("required"),
public_key_pem: env.get("JWT_PUBLIC_KEY").expect("required"),
expiration_seconds: 3600,
},
cors_origins: vec![], // empty = permissive
};
let mut ctx = ApplicationContext::new(env);
// Register services, repos, etc. here
ctx.validate().map_err(|errs| anyhow::anyhow!("{errs:?}"))?;
// Every #[get]/#[post]/etc.-annotated handler in the binary is auto-registered.
run(Arc::new(ctx), utoipa_axum::router::OpenApiRouter::new(), config).await
}
#[get(path = "/health", responses((status = 200, body = String)))]
async fn health_handler() -> &'static str {
"ok"
}Run:
cargo run
curl http://localhost:3000/healthWires Axum router with a fixed middleware stack and manages graceful shutdown. #[get(...)]/#[post(...)]/#[put(...)]/#[delete(...)]/#[patch(...)] replace #[utoipa::path(...)], inferring operation_id/request_body/the 200 response/params(...) from the handler's signature, and auto-registering the handler into a utoipa_axum::router::OpenApiRouter — so an OpenAPI document is generated as a byproduct of registration, no hand-maintained spec file. Set BootstrapConfig.enable_swagger to mount Swagger UI at /swagger and the spec at /api-docs/openapi.json.
Key types: BootstrapConfig, #[get]/#[post]/#[put]/#[delete]/#[patch], run()
Provides:
- Auto-registering
#[get]/#[post]/etc. attributes — no manual.routes(routes!(handler))calls and no separate#[utoipa::path(...)]attribute to keep in sync. No built-in auth mechanism yet (planned as its own macro) — apply guards by hand to routes you compose yourself and pass viaextra_routes - Full middleware stack: CORS, timeout, tracing, error logging, panic handling, compression, request-id, body limits, security headers
- Graceful shutdown on SIGINT/SIGTERM
- Server bootstrap via
run(ctx, extra_routes, config)listening on configured port
A lightweight IoC container with:
- Lazy singleton resolution via
OnceLock - Type-safe dependency injection with macros
- Cycle detection during validation
- Environment variable loading via
dotenvy
Key types: Container, ApplicationContext, Environment
Procedural macros that generate boilerplate:
#[component]— marks traits as injectable components#[service(dyn Trait)]— marks impls as trait object factories
Platform primitives used everywhere:
id_type!macro — generates typed UUID wrappers (uuid_v4anduuid_v7variants)ServiceError— standard service layer error typeDomainError— business logic error typedomain_err_to_service()— conversion utility
JWT and security context:
Claims<E>— generalized JWT payload (genericEfor app-defined extensions: email, role, custom claims)SecurityContext<E>— wraps claims for request contextJwtConfig,encode_jwt(),decode_jwt()— JWT utilities (ES256)
Axum extractors, error handling, and middleware:
AppJson<T>,AppPath<T>,AppQuery<T>— custom extractors with structured errorsApiError— unified HTTP error type that mapsServiceError→ HTTP statusErrorResponse— standard JSON error responseerror_logging_middleware— logs errors with request context
SeaORM helpers:
DbConfig— manages database connection poolsMapper<Domain>— trait for bidirectional domain ↔ DB conversion
Four convenience crates bundle platform crates by app layer, reducing Cargo.toml boilerplate:
stano-starter— re-exportsstano-common,stano-di,stano-di-macros(domain/DI foundation).stano-starter-domain— thin re-export ofstano-starterunder a domain-focused name.stano-starter-service— re-exportsstano-starter+stano-security(domain + DI + JWT).stano-starter-rest— re-exportsstano-di+stano-axum+stano-launcher+stano-security(HTTP layer).
Each contains no code of its own — use them to simplify dependency declarations in your app's layers. See each crate's README.md for details.
use stano_common::id_type;
id_type!(UserId, uuid_v7); // Sortable
id_type!(TripId, uuid_v4); // Randomuse stano_common::{DomainError, id_type};
id_type!(AccountId, uuid_v7);
pub struct Account {
account_id: AccountId,
email: String,
// ... private fields
}
impl Account {
pub fn new(email: String) -> Result<Self, DomainError> {
if email.is_empty() {
return Err(DomainError::InvalidInput("email required".into()));
}
Ok(Self {
account_id: AccountId::new(),
email,
})
}
}use stano_di_macros::{component, service};
use stano_common::ServiceError;
use std::sync::Arc;
#[component]
#[async_trait::async_trait]
pub trait UserService: Send + Sync {
async fn get_user(&self, id: &UserId) -> Result<UserDto, ServiceError>;
}
#[service(dyn UserService)]
pub struct UserServiceImpl {
user_repo: Arc<dyn UserRepository>,
}
#[async_trait::async_trait]
impl UserService for UserServiceImpl {
async fn get_user(&self, id: &UserId) -> Result<UserDto, ServiceError> {
self.user_repo
.find(id)
.await
.map_err(|e| ServiceError::Internal(e))?
.map(|u| UserDto::from(u))
.ok_or(ServiceError::NotFound)
}
}use stano_axum::{AppJson, AppPath};
use stano_security::SecurityContext;
async fn get_user(
ctx: SecurityContext, // Auto-extracted if JWT valid, else 401
AppPath(user_id): AppPath<UserId>,
State(s): State<AppState>,
) -> Result<AppJson<UserDto>, ApiError> {
let service = s.application_context.get::<dyn UserService>();
Ok(AppJson(service.get_user(&user_id).await?))
}DomainError (InvalidInput, BusinessRuleViolation)
↓
(converted by services layer)
↓
ServiceError (NotFound, InvalidInput, Conflict, Unauthorized, Forbidden, Internal)
↓
(via From impl in rest_api)
↓
ApiError → HTTP response (404, 400, 409, 401, 403, 500)
// Generated via sea-orm-cli
#[derive(Clone, Debug, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub email: String,
pub created_at: joda_rs::ZonedDateTime,
}use stano_seaorm::Mapper;
impl Mapper<Account> for AccountMapper {
type Model = user::Model;
type ActiveModel = user::ActiveModel;
fn to_domain(model: user::Model) -> Account {
Account {
account_id: AccountId::from(model.id),
email: model.email,
}
}
fn to_active_model(domain: &Account) -> user::ActiveModel {
user::ActiveModel {
id: Set(*domain.account_id.as_uuid()),
email: Set(domain.email.clone()),
}
}
}cargo build
cargo test --workspace
cargo clippy -- -D warnings
cargo fmt --checkAll crates are:
- ✅ Zero warnings under Clippy
- ✅ Fully formatted with
rustfmt - ✅ Test-covered
- ✅ Properly documented
stano-launcher wires OTLP-based tracing, metrics, and log export automatically via stano_launcher::observability::init_observability, which run() calls before building the router. Everything is opt-in and safe for local dev with no collector running — with no env vars set, you get local fmt/JSON console logging only.
Env vars (read via stano_di::environment::Environment, so .env works too):
| Var | Default | Purpose |
|---|---|---|
STANO_OTEL_ENABLED |
false |
Master switch — enables OTLP trace and log export |
STANO_OTEL_METRICS_ENABLED |
false |
Enables OTLP metric export (independent of STANO_OTEL_ENABLED) |
STANO_PROMETHEUS_ENABLED |
false |
Exposes a local Prometheus scrape endpoint at GET /metrics — no collector required |
STANO_HTTP_LOGGING_ENABLED |
false |
Logs every HTTP request (method, URI, status, latency, trace_id) |
OTEL_EXPORTER_OTLP_PROTOCOL |
grpc |
grpc (port 4317) or http/protobuf (port 4318) |
OTEL_EXPORTER_OTLP_ENDPOINT |
http://localhost:4317 / :4318 |
Collector endpoint, protocol-dependent default |
OTEL_SERVICE_NAME |
stano-app |
service.name resource attribute |
OTEL_SERVICE_VERSION |
0.0.0 |
service.version resource attribute |
OTEL_TRACES_SAMPLER_ARG |
1.0 |
Trace sampling ratio (0.0–1.0) |
RUST_LOG |
info |
tracing_subscriber::EnvFilter directive string |
What you get:
- Traces — OTLP spans via
tracing_opentelemetry, so anytracing::span!/#[tracing::instrument]and theTraceLayerrequest span are exported automatically onceSTANO_OTEL_ENABLED=true. - Metrics — either pushed to an OTLP collector (
STANO_OTEL_METRICS_ENABLED=true) and/or scraped locally via Prometheus (STANO_PROMETHEUS_ENABLED=true); built-in HTTP server metrics (http.server.request.duration,http.server.active_requests) are recorded automatically when metrics are enabled. - Logs — every
tracing::event is exported as an OTLP log record onceSTANO_OTEL_ENABLED=true, alongside the always-on local JSON console output.
stano-seaorm mirrors this pattern for DB query tracing (STANO_DB_TRACING_ENABLED, STANO_DB_TRACING_INCLUDE_STATEMENT, STANO_DB_SLOW_QUERY_MS), emitting tracing events per query rather than dedicated spans.
stano-launcher applies this stack in request-processing order (outermost/first-to-see-request → innermost/closest-to-handlers):
- Security Headers —
x-content-type-options: nosniff,x-frame-options: DENY, HSTS - RequestBodyLimit — 10 MB max
- PropagateRequestId — propagates upstream
- SetRequestId — injects
x-request-id - Compression — gzip/brotli/deflate auto-negotiated
- CatchPanic — panics become 500 responses
- error_logging_middleware — logs
ApiErrorwith request context - TraceLayer — structured logging
- Timeout — 300s per request
- CORS — configurable origins or permissive
| Crate | Purpose | Key Dependencies |
|---|---|---|
stano-launcher |
App bootstrap | stano-di, stano-axum, stano-security, axum, tokio, tower-http |
stano-di |
DI container | dotenvy, thiserror |
stano-di-macros |
Macros | proc-macro, quote, syn |
stano-common |
Shared types | uuid, serde, thiserror, anyhow |
stano-security |
JWT/Auth | jsonwebtoken, tokio, serde |
stano-axum |
HTTP | axum, serde, tokio |
stano-seaorm |
Database | sea-orm, tokio |
Recommended structure for apps consuming these crates:
my-app/
src/
main.rs # App entry point, calls stano_launcher::run()
domain/ # Pure business logic (no external deps)
mod.rs
user.rs # Entity definitions
infrastructure/ # Database adapters
mod.rs
user_repo.rs # SeaORM repositories + Mapper impls
services/ # Business orchestration
mod.rs
user_service.rs # Service traits & impls, DTOs
http/ # HTTP handlers & routes
mod.rs
user_routes.rs # Route definitions
Cargo.toml # Depends on stano-* crates
.env # JWT keys, database URL, etc.
Flow: HTTP handler → calls service → calls repository → maps domain entities ↔ database models.
| Layer | Purpose | Error Type | External Deps Allowed |
|---|---|---|---|
| Domain | Pure business logic | DomainError |
None |
| Infrastructure | DB adapters, external services | anyhow::Error |
SeaORM, HTTP clients |
| Services | Orchestration, guards | ServiceError |
Domain, Infra, macros |
| HTTP | Request/response mapping | ApiError |
Services, stano-axum, stano-launcher |
| App (main.rs) | Bootstrap & wiring | anyhow::Error |
All of the above |
Domain has zero external dependencies — it's pure Rust. Everything else builds on it.
DomainError (InvalidInput, BusinessRuleViolation)
↓ (domain_err_to_service())
ServiceError (NotFound, InvalidInput, Conflict, Unauthorized, Forbidden, Internal)
↓ (impl IntoResponse)
ApiError
↓
HTTP status (400, 401, 403, 404, 409, 500)
These crates are part of the Stano platform.