| Crates.io | stately |
| lib.rs | stately |
| version | 0.5.0 |
| created_at | 2025-10-31 00:46:07.031826+00 |
| updated_at | 2026-01-05 21:01:41.529542+00 |
| description | Type-safe state management with entity relationships and CRUD operations |
| homepage | https://github.com/georgeleepatterson/stately |
| repository | https://github.com/georgeleepatterson/stately |
| max_upload_size | |
| id | 1909167 |
| size | 192,447 |
Type-safe state management with entity relationships and CRUD operations
Stately provides a framework for managing application configuration and state with built-in support for:
utoipaStately does not provide the configuration and structures that comprise the state. Instead it provides an ultra-thin container management strategy that provides seamless integration with @statelyjs/stately.
Add to your Cargo.toml:
[dependencies]
stately = "0.5.0"
With Axum API generation:
[dependencies]
stately = { version = "0.5.0", features = ["axum"] }
Use the #[stately::entity] macro to define your domain entities:
use stately::prelude::*;
#[stately::entity]
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct Pipeline {
pub name: String,
pub source: Link<SourceConfig>,
}
#[stately::entity]
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct SourceConfig {
pub name: String,
pub url: String,
}
Use the #[stately::state] macro to create your application state:
#[stately::state]
pub struct AppState {
pipelines: Pipeline,
sources: SourceConfig,
}
This generates:
StateEntry enum for entity type discriminationEntity enum for type-erased entity accessForeignEntity trait for externally defined entitiesUse #[collection(...)] to customize how collections are generated:
// Can be a struct that implements `StateCollection`. This type alias is for simplicity.
type CustomStateCollectionImpl = Collection<SourceConfig>;
#[stately::state]
pub struct AppState {
#[collection] // Default, same as omitting
pipelines: Pipeline,
#[collection(CustomStateCollectionImpl)]
sources: SourceConfig,
// variant = "..." sets the name used in the StateEntry and Entity enums
// Useful when multiple collections use the same entity type
#[collection(CustomStateCollectionImpl, variant = "CachedSourceConfig")]
sources_cached: SourceConfig,
// foreign allows using types from external crates
#[collection(foreign)]
configs: serde_json::Value,
}
Without variant, the macro generates enum variant names from the entity type name. Use variant to:
StateEntry and Entity enumsStateEntry::CachedSourceConfig vs StateEntry::SourceConfig)let mut state = AppState::new();
// Create entities
let source_id = state.sources.create(SourceConfig {
name: "my-source".to_string(),
url: "http://example.com".to_string(),
});
// Reference entities
let pipeline = Pipeline {
name: "my-pipeline".to_string(),
source: Link::create_ref(source_id.to_string()),
};
let pipeline_id = state.pipelines.create(pipeline);
// Query
let (id, entity) = state.get_entity(&pipeline_id.to_string(), StateEntry::Pipeline).unwrap();
// List all
let summaries = state.list_entities(None);
// Search
let results = state.search_entities("my-pipeline");
// Update
state.pipelines.update(&pipeline_id.to_string(), updated_pipeline)?;
// Delete
state.pipelines.remove(&pipeline_id.to_string())?;
use stately::prelude::*;
// Define your entities
#[stately::entity]
#[derive(Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct Pipeline {
pub name: String,
pub source: Link<SourceConfig>,
pub sink: Link<SinkConfig>,
}
#[stately::entity]
#[derive(Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct SourceConfig {
pub name: String,
pub url: String,
}
#[stately::entity]
#[derive(Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct SinkConfig {
pub name: String,
pub destination: String,
}
// Define your application state
#[stately::state(openapi)]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct AppState {
pipelines: Pipeline,
sources: SourceConfig,
sinks: SinkConfig,
}
fn main() {
let mut state = AppState::new();
// Create entities
let source_id = state.sources.create(SourceConfig {
name: "my-source".to_string(),
url: "http://example.com/data".to_string(),
});
let sink_id = state.sinks.create(SinkConfig {
name: "my-sink".to_string(),
destination: "s3://my-bucket/output".to_string(),
});
// Create a pipeline referencing the source and sink
let mut pipeline = Pipeline {
name: "my-pipeline".to_string(),
source: Link::create_ref(source_id.to_string()),
sink: Link::create_ref(sink_id.to_string()),
};
let pipeline_id = state.pipelines.create(pipeline.clone());
// Query entities
if let Some((id, entity)) = state.get_entity(&pipeline_id.to_string(), StateEntry::Pipeline) {
println!("Found pipeline: {:?}", entity);
}
// List all entities
let summaries = state.list_entities(None);
for (entry, entities) in summaries {
println!("{}: {} entities", entry.as_ref(), entities.len());
}
// Update
pipeline.name = "my-pipeline-updated".to_string();
state.pipelines.update(&pipeline_id.to_string(), pipeline)?;
// Delete
state.pipelines.remove(&pipeline_id.to_string())?;
// Search across collections
let results = state.search_entities("pipeline");
}
See the examples directory:
basic.rs - Core CRUD operations and entity relationshipsaxum_api.rs - Web API generation with Axumdoc_expand.rs - Example used to generate documentationRun examples:
cargo run --example basic
cargo run --example axum_api --features axum
Link<T>The Link<T> type allows flexible entity references:
// Reference by ID
let link = Link::create_ref("source-id-123");
// Inline embedding
let link = Link::inline(SourceConfig {
name: "inline-source".to_string(),
url: "http://example.com".to_string(),
});
// Access
match &pipeline.source {
Link::Ref(id) => println!("References source: {}", id),
Link::Inline(source) => println!("Inline source: {}", source.name),
}
For configuration that should have exactly one instance:
#[stately::entity]
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct Settings {
pub max_connections: usize,
}
#[stately::state]
pub struct AppState {
#[singleton]
settings: Settings,
}
Stately allows you to use types from external crates (foreign types) in your state by using the #[collection(foreign)] attribute. This is useful for managing third-party types like configuration formats, API responses, or other external data structures.
When you mark a collection as foreign, the #[stately::state] macro generates a ForeignEntity trait in your crate that you can implement on external types:
use serde_json::Value;
#[stately::state]
pub struct AppState {
#[collection(foreign, variant = "JsonConfig")]
json_configs: Value,
}
// The macro generates this trait in your crate:
// pub trait ForeignEntity: Clone + Serialize + for<'de> Deserialize<'de> {
// fn name(&self) -> &str;
// fn description(&self) -> Option<&str> { None }
// fn summary(&self, id: EntityId) -> Summary { ... }
// }
// Now you can implement it on the external type
impl ForeignEntity for Value {
fn name(&self) -> &str {
self.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unnamed")
}
fn description(&self) -> Option<&str> {
self.get("description")
.and_then(|v| v.as_str())
}
}
// Use like any other entity
let mut state = AppState::new();
let config = serde_json::json!({
"name": "my-config",
"description": "A JSON configuration"
});
let id = state.json_configs.create(config);
Because ForeignEntity is generated in your crate (not in stately), you can implement it on types from external crates without violating Rust's orphan rules. The macro creates wrapper types in the Entity enum that delegate to your ForeignEntity implementation, ensuring full compatibility with state operations.
Generate a complete REST API with OpenAPI documentation:
#[stately::state(openapi)]
pub struct State {
pipelines: Pipeline,
}
#[stately::axum_api(State, openapi(components = [link_aliases::PipelineLink]))]
pub struct ApiState {}
// Now in scope:
// - Trait implementations
// - All endpoints, response, request, and query types and ResponseEvent enum
// - `link_aliases` module
// - `impl AppState` with all state methods
#[tokio::main]
async fn main() {
let app_state = ApiState::new(State::new());
let app = axum::Router::new()
.nest("/api/v1/entity", ApiState::router(app_state.clone()))
.with_state(app_state);
// Generated routes:
// PUT /api/v1/entity - Create entity
// GET /api/v1/entity - List all entities by StateEntry
// GET /api/v1/entity/list - List all entities as summaries
// GET /api/v1/entity/list/{type} - List all entities filtered by type as summaries
// GET /api/v1/entity/{id}?type=<type> - Get entity by ID
// POST /api/v1/entity/{id} - Update entity
// PATCH /api/v1/entity/{id} - Patch entity
// DELETE /api/v1/entity/{entry}/{id} - Delete entity
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
The axum_api macro generates a ResponseEvent enum and event_middleware() method for integrating with databases:
use tokio::sync::mpsc;
// Your event enum that wraps ResponseEvent
pub enum ApiEvent {
StateEvent(ResponseEvent),
}
// Implement From<ResponseEvent> for ApiEvent
impl From<ResponseEvent> for ApiEvent {
fn from(event: ResponseEvent) -> Self {
ApiEvent::StateEvent(event)
}
}
let (event_tx, mut event_rx) = mpsc::channel(100);
let app = axum::Router::new()
.nest("/api/v1/entity", ApiState::router(app_state.clone()))
.layer(axum::middleware::from_fn(
ApiState::event_middleware::<ApiEvent>(event_tx)
))
.with_state(app_state);
// Background task to handle events
tokio::spawn(async move {
while let Some(ApiEvent::StateEvent(event)) = event_rx.recv().await {
match event {
// Persist to database after state update
ResponseEvent::Created { id, entity } => db.insert(id, entity).await,
ResponseEvent::Updated { id, entity } => db.update(id, entity).await,
ResponseEvent::Deleted { id, entry } => db.delete(id, entry).await,
}
}
});
The axum_api macro generates:
openapi parameter)router() method and ApiState::openapi() for docsResponseEvent enum and event_middleware() for event-driven persistence#[stately::state(openapi)] - Enables OpenAPI schema generation for entities#[stately::axum_api(State, openapi(components = [...]))]
openapi: Enable OpenAPI documentation generationopenapi(components = [...]): Additional types to include in OpenAPI schemas (e.g., Link types)The axum_api macro generates these endpoints:
PUT / - Create a new entityGET / - Get all entitiesGET /list - List all entities by summaryGET /list/{type} - List all entities filtered by type by summaryGET /{id}?type=<type> - Get entity by ID and typePOST /{id} - Update an existing entityPATCH /{id} - Patch an existing entityDELETE /{entry}/{id} - Delete an entityAccess the generated OpenAPI spec:
use utoipa::OpenApi;
#[stately::state(openapi)]
pub struct State {
pipelines: Pipeline,
}
#[stately::axum_api(State, openapi(components = [link_aliases::PipelineLink]))]
pub struct ApiState {}
let openapi = ApiState::openapi();
let json = openapi.to_json().unwrap();
| Feature | Description | Default |
|---|---|---|
openapi |
Enable OpenAPI schema generation via utoipa |
✅ Yes |
axum |
Enable Axum web framework integration | ❌ No |
The #[stately::entity] macro implements the HasName trait and supports these attributes:
// Default: uses the "name" field
#[stately::entity]
// Use a different field for the entity name
#[stately::entity(name_field = "title")]
// Use a method to get the name
#[stately::entity(name_method = "get_identifier")]
Collection<T> - A collection of entities with CRUD operationsSingleton<T> - A single entity instanceLink<T> - Reference to another entity (by ID or inline)EntityId - UUID v7 identifier for entitiesSummary - Lightweight entity summary for listingsHasName - Trait for providing entity names (implemented by #[stately::entity])StateEntity - Trait for all entity types (implemented by #[stately::state])StateCollection - Trait for entity collections (implemented by #[stately::state])#[stately::entity] - Implements the HasName trait for an entity type#[stately::state] - Define application state with entity collectionsStately uses procedural macros to generate boilerplate at compile time:
#[stately::entity] implements the HasName trait#[stately::state] generates:
StateEntry enum for entity type discriminationEntity enum for type-erased entity wrapperForeignEntity for external entitieslink_aliases module with Link<T> type aliases#[stately::axum_api(State, ...)] generates (optional):
router() method for Axum integrationopenapi parameter is used)ResponseEvent enum for CRUD operationsevent_middleware() method for event streamingAll generated code is type-safe and benefits from Rust's compile-time guarantees.
link_aliases Module (from #[stately::state]):
pub mod link_aliases {
pub type PipelineLink = ::stately::Link<Pipeline>;
pub type SourceLink = ::stately::Link<Source>;
// ... one type alias for each entity in your state
}
ResponseEvent Enum (from #[stately::axum_api]):
pub enum ResponseEvent {
Created { id: EntityId, entity: Entity },
Updated { id: EntityId, entity: Entity },
Deleted { id: EntityId, entry: StateEntry },
}
These enable type-safe event-driven architectures for persistence, logging, and system integration.
Licensed under the Apache License, Version 2.0. See LICENSE for details.