| Crates.io | hexser |
| lib.rs | hexser |
| version | 0.4.7 |
| created_at | 2025-10-07 01:25:44.96118+00 |
| updated_at | 2025-10-13 00:10:18.861353+00 |
| description | Zero-boilerplate hexagonal architecture with graph-based introspection |
| homepage | |
| repository | https://github.com/squillo/hexser/hex |
| max_upload_size | |
| id | 1871068 |
| size | 810,153 |
Zero-boilerplate hexagonal architecture with graph-based introspection for Rust.
The hexser crate provides reusable generic types and traits for implementing Hexagonal Architecture (Ports and Adapters pattern) with automatic graph construction, intent inference, and architectural validation. Write business logic, let hexser handle the architecture.
Tip: Press Cmd/Ctrl+F and search for “Part” to jump to tutorials.
Traditional hexagonal architecture requires significant boilerplate:
hexser eliminates all of this. Through intelligent trait design, compile-time graph construction, and rich error handling, you get:
Add to your Cargo.toml:
[dependencies]
hexser = "0.4.7"
Your First Hexagonal Application
use hexser::prelude::*;
// 1. Define your domain entity
#[derive(Entity)]
struct User {
id: String,
email: String,
name: String,
}
// 2. Define a port (interface)
#[derive(HexPort)]
trait UserRepository: Repository<User> {
fn find_by_email(&self, email: &str) -> HexResult<Option<User>>;
}
// 3. Implement an adapter
#[derive(HexAdapter)]
struct InMemoryUserRepository {
users: Vec<User>,
}
impl Repository<User> for InMemoryUserRepository {
fn save(&mut self, user: User) -> HexResult<()> {
if let Some(existing) = self.users.iter_mut().find(|u| u.id == user.id) {
*existing = user;
} else {
self.users.push(user);
}
Ok(())
}
}
impl UserRepository for InMemoryUserRepository {
fn find_by_email(&self, email: &str) -> HexResult<Option<User>> {
Ok(self.users.iter().find(|u| u.email == email).cloned())
}
}
// 4. Use it!
fn main() -> HexResult<()> {
let mut repo = InMemoryUserRepository { users: Vec::new() };
let user = User {
id: "1".to_string(),
email: "alice@example.com".to_string(),
name: "Alice".to_string(),
};
repo.save(user)?;
let found = repo.find_by_email("alice@example.com")?;
println!("Found: {:?}", found.map(|u| u.name));
Ok(())
}
That's it! You've just built a hexagonal architecture application with:
Hexser provides granular feature flags to enable only the functionality you need. This keeps compile times fast and binary sizes small, especially for WASM targets.
default = ["macros", "static-di"]Enabled by default. Includes procedural macros and zero-cost static dependency injection.
[dependencies]
hexser = "0.4.7" # Uses default features
macrosEnables procedural macros for deriving hexagonal architecture traits.
Provides:
#[derive(HexEntity)] - Implement HexEntity trait for domain entities#[derive(HexValueItem)] - Implement HexValueItem trait with default validation (override validate() for custom logic)#[derive(HexAggregate)] - Mark aggregate roots#[derive(HexPort)] - Mark port traits#[derive(HexAdapter)] - Mark adapter implementations#[derive(HexRepository)] - Mark repository ports#[derive(HexDirective)] - Mark command/directive types#[derive(HexQuery)] - Mark query typesDependencies: hexser_macros
[dependencies]
hexser = { version = "0.4.7", default-features = false, features = ["macros"] }
static-diZero-cost, WASM-friendly static dependency injection. No runtime overhead, no dynamic dispatch.
Provides:
StaticContainer for compile-time dependency resolutiondynDependencies: None (zero-cost abstraction)
[dependencies]
hexser = { version = "0.4.7", features = ["static-di"] }
Example:
use hexser::prelude::*;
let container = StaticContainer::new()
.with_service(MyRepository::new())
.with_service(MyService::new());
let service = container.get::<MyService>();
aiEnables AI context export functionality for exposing architecture metadata to AI agents.
Provides:
AIContext type with architecture metadataAgentPack for packaging contexthex-ai-export, hex-ai-packmethods field capturing method signatures, parameters, return types, and documentation (currently empty, ready for future extraction via rustdoc JSON)Dependencies: chrono, serde, serde_json
[dependencies]
hexser = { version = "0.4.7", features = ["ai"] }
Usage:
# Export architecture context to JSON
cargo run --bin hex-ai-export > context.json
# Create agent pack
cargo run --bin hex-ai-pack --output agent-pack.json
mcpModel Context Protocol server implementation for serving architecture data via JSON-RPC.
Provides:
hexser://context, hexser://packhex-mcp-serverDependencies: Requires ai feature, plus serde, serde_json
[dependencies]
hexser = { version = "0.4.7", features = ["mcp"] }
Usage:
# Start MCP server (communicates via stdin/stdout)
cargo run --bin hex-mcp-server
asyncEnables async/await support for ports and adapters.
Provides:
AsyncRepository traitAsyncDirective traitAsyncQuery traitDependencies: tokio, async-trait
[dependencies]
hexser = { version = "0.4.7", features = ["async"] }
Example:
#[async_trait::async_trait]
impl AsyncRepository<User> for AsyncUserRepo {
async fn find_by_id(&self, id: &String) -> HexResult<Option<User>> {
// async implementation
}
}
visualizationEnables graph visualization and export capabilities.
Provides:
Dependencies: serde, serde_json
[dependencies]
hexser = { version = "0.4.7", features = ["visualization"] }
containerDynamic dependency injection container with async support. Not enabled by default to maintain WASM compatibility.
Provides:
DynContainer with runtime service resolutiondyn traitsDependencies: tokio, async-trait
Note: Use static-di instead if you need WASM compatibility or want zero runtime overhead.
[dependencies]
hexser = { version = "0.4.7", features = ["container"] }
fullEnables all features: ai, mcp, async, macros, visualization, container, and static-di.
Use for: Development, full-featured applications, or when you need all capabilities.
[dependencies]
hexser = { version = "0.4.7", features = ["full"] }
Hexser includes three command-line tools that require specific features:
hex-ai-exportExports architecture context as JSON for AI consumption.
Required feature: ai
cargo run --bin hex-ai-export --features ai > context.json
hex-ai-packCreates a complete agent pack with architecture metadata.
Required feature: ai
cargo run --bin hex-ai-pack --features ai --output pack.json
hex-mcp-serverRuns an MCP (Model Context Protocol) server over stdio.
Required feature: mcp
cargo run --bin hex-mcp-server --features mcp
[dependencies]
hexser = { version = "0.4.7", default-features = false }
[dependencies]
hexser = { version = "0.4.7", default-features = false, features = ["macros", "static-di"] }
[dependencies]
hexser = { version = "0.4.7", features = ["ai", "async", "visualization"] }
[dependencies]
hexser = { version = "0.4.7", features = ["full"] }
Hexagonal Architecture (also known as Ports and Adapters) structures applications into concentric layers:
┌─────────────────────────────────────────────┐
│ Infrastructure Layer │
│ (Databases, APIs, External Services) │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ Adapters Layer │ │
│ │ (Concrete Implementations) │ │
│ │ │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ Ports Layer │ │ │
│ │ │ (Interfaces/Contracts) │ │ │
│ │ │ │ │ │
│ │ │ ┌───────────────────────────┐ │ │ │
│ │ │ │ Domain Layer │ │ │ │
│ │ │ │ (Business Logic) │ │ │ │
│ │ │ └───────────────────────────┘ │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
Key Principles:
Dependency Rule: Dependencies point inward (Domain has no dependencies)
Port Interfaces: Define what the domain needs (don't dictate how)
Adapter Implementations: Provide concrete implementations using specific tech
Testability: Mock adapters for testing without infrastructure
use hexser::prelude::*;
#[derive(Entity)]
struct Order {
id: OrderId,
customer_id: CustomerId,
items: Vec<OrderItem>,
status: OrderStatus,
}
impl Aggregate for Order {
fn check_invariants(&self) -> HexResult<()> {
if self.items.is_empty() {
return Err(hexser::hex_domain_error!(
hexser::error::codes::domain::INVARIANT_EMPTY,
"Order must contain at least one item"
).with_next_step("Add at least one item"));
}
Ok(())
}
}
Value Objects - Things defined by values:
#[derive(Clone, PartialEq, Eq)]
struct Email(String);
impl HexValueItem for Email {
fn validate(&self) -> HexResult<()> {
if !self.0.contains('@') {
return Err(Hexserror::validation("Email must contain @"));
}
Ok(())
}
}
Domain Events - Things that happened:
struct OrderPlaced {
order_id: OrderId,
customer_id: CustomerId,
timestamp: u64,
}
impl DomainEvent for OrderPlaced {
// Manual implementation - no derive macro available
}
Domain Services - Operations spanning multiple entities:
struct PricingService;
impl DomainService for PricingService {
// Manual implementation - no derive macro available
}
impl PricingService {
fn calculate_order_total(&self, order: &Order) -> Money {
order.items
.iter()
.map(|item| item.price * item.quantity)
.sum()
}
}
#[derive(HexPort)]
trait OrderRepository: Repository<Order> {
fn find_by_customer(&self, customer_id: &CustomerId)
-> HexResult<Vec<Order>>;
fn find_pending(&self) -> HexResult<Vec<Order>>;
}
Use Cases - Business operations:
#[derive(HexPort)]
trait PlaceOrder: UseCase<PlaceOrderInput, PlaceOrderOutput> {}
struct PlaceOrderInput {
customer_id: CustomerId,
items: Vec<OrderItem>,
}
struct PlaceOrderOutput {
order_id: OrderId,
}
Queries - Read operations (CQRS):
#[derive(HexPort)]
trait OrderHistory: Query<OrderHistoryParams, Vec<OrderView>> {}
struct OrderHistoryParams {
customer_id: CustomerId,
from_date: u64,
to_date: u64,
}
struct OrderView {
order_id: String,
total: f64,
status: String,
}
Database Adapter:
#[derive(HexAdapter)]
struct PostgresOrderRepository {
pool: PgPool,
}
impl Repository<Order> for PostgresOrderRepository {
fn save(&mut self, order: Order) -> HexResult<()> {
// SQL insert/update implementation
todo!()
}
}
impl OrderRepository for PostgresOrderRepository {
fn find_by_customer(&self, customer_id: &CustomerId)
-> HexResult<Vec<Order>> {
// Custom query implementation
todo!()
}
fn find_pending(&self) -> HexResult<Vec<Order>> {
// Custom query implementation
todo!()
}
}
API Adapter:
#[derive(HexAdapter)]
struct RestPaymentGateway {
client: reqwest::Client,
api_key: String,
}
impl PaymentPort for RestPaymentGateway {
fn charge(&self, amount: Money, card: &Card) -> HexResult<PaymentResult> {
// HTTP API call implementation
todo!()
}
}
Mapper - Data transformation:
#[derive(HexAdapter)]
struct OrderMapper;
impl Mapper<Order, DbOrderRow> for OrderMapper {
fn map(&self, order: Order) -> HexResult<DbOrderRow> {
Ok(DbOrderRow {
id: order.id.to_string(),
customer_id: order.customer_id.to_string(),
items_json: serde_json::to_string(&order.items)?,
status: order.status.to_string(),
})
}
}
#[derive(HexDirective)]
struct PlaceOrderDirective {
customer_id: CustomerId,
items: Vec<OrderItem>,
}
impl PlaceOrderDirective {
fn validate(&self) -> HexResult<()> {
if self.items.is_empty() {
return Err(Hexserror::validation("Items cannot be empty"));
}
Ok(())
}
}
Directive Handler:
#[derive(HexDirectiveHandler)]
struct PlaceOrderHandler {
order_repo: Box<dyn OrderRepository>,
payment_port: Box<dyn PaymentPort>,
}
impl PlaceOrderHandler {
fn handle(&self, directive: PlaceOrderDirective) -> HexResult<()> {
// Validate
directive.validate()?;
// Create domain object
let order = Order::new(directive.customer_id, directive.items)?;
// Check invariants
order.check_invariants()?;
// Save
self.order_repo.save(order)?;
// Side effects
self.payment_port.charge(order.total(), &order.payment_method)?;
Ok(())
}
}
Query Handler:
#[derive(HexQueryHandler)]
struct OrderHistoryHandler {
query_repo: Box<dyn OrderQueryRepository>,
}
impl OrderHistoryHandler {
fn handle(&self, params: OrderHistoryParams) -> HexResult<Vec<OrderView>> {
self.query_repo.get_order_history(
¶ms.customer_id,
params.from_date,
params.to_date
)
}
}
#[derive(HexConfig)]
struct DatabaseConfig {
connection_string: String,
pool_size: u32,
}
impl DatabaseConfig {
fn create_pool(&self) -> PgPool {
// Create database connection pool
todo!()
}
}
hexser supports Command Query Responsibility Segregation (CQRS) out of the box.
Write Side (Directives):
// Directive represents intent to change state
#[derive(HexDirective)]
struct UpdateUserEmail {
user_id: UserId,
new_email: Email,
}
impl UpdateUserEmail {
fn validate(&self) -> HexResult<()> {
self.new_email.validate()
}
}
// Handler executes the directive
#[derive(HexDirectiveHandler)]
struct UpdateUserEmailHandler {
repo: Box<dyn UserRepository>,
}
impl UpdateUserEmailHandler {
fn handle(&self, directive: UpdateUserEmail) -> HexResult<()> {
let mut user = self.repo.find_by_id(&directive.user_id)?
.ok_or_else(|| Hexserror::not_found("User", &directive.user_id))?;
user.email = directive.new_email;
self.repo.save(user)?;
Ok(())
}
}
Read Side (Queries):
// Query represents read operation
#[derive(HexQuery)]
struct FindUserByEmail {
email: String,
}
// Handler executes the query
#[derive(HexQueryHandler)]
struct FindUserByEmailHandler {
query_repo: Box<dyn UserQueryRepository>,
}
impl FindUserByEmailHandler {
fn handle(&self, query: FindUserByEmail)
-> HexResult<Option<UserView>> {
self.query_repo.find_by_email(&query.email)
}
}
The Application trait marks top-level entry points and coordinates the system lifecycle in hexagonal architecture. It orchestrates initialization of adapters, ports, and domain services, and manages the application from startup to shutdown.
use hexser::prelude::*;
struct WebApplication {
port: u16,
user_handler: UpdateUserEmailHandler,
query_handler: FindUserByEmailHandler,
}
impl Application for WebApplication {
fn name(&self) -> &str {
"WebApplication"
}
fn initialize(&mut self) -> HexResult<()> {
println!("Initializing web server on port {}", self.port);
// Load configuration
// Set up dependency injection
// Initialize adapters and ports
// Validate system state
Ok(())
}
fn run(&mut self) -> HexResult<()> {
println!("Starting web server...");
// Start web server or event loop
// Process incoming requests
// Execute directives and queries
Ok(())
}
fn shutdown(&mut self) -> HexResult<()> {
println!("Shutting down web server...");
// Close database connections
// Flush buffers
// Save state
// Release resources
Ok(())
}
}
fn main() -> HexResult<()> {
let mut app = WebApplication {
port: 8080,
user_handler: UpdateUserEmailHandler {
repo: Box::new(PostgresUserRepository::new()),
},
query_handler: FindUserByEmailHandler {
query_repo: Box::new(PostgresUserQueryRepository::new()),
},
};
// Execute full lifecycle: initialize, run, shutdown
app.execute()
}
The Application trait follows hexser's zero-boilerplate philosophy. All lifecycle methods have default implementations, so you only override what you need:
use hexser::prelude::*;
struct MinimalApp;
impl Application for MinimalApp {
fn name(&self) -> &str {
"MinimalApp"
}
// initialize(), run(), and shutdown() have default no-op implementations
}
fn main() -> HexResult<()> {
let mut app = MinimalApp;
app.execute() // Works perfectly with defaults
}
Here's a complete example showing how the Application trait coordinates Directives and Queries:
use hexser::prelude::*;
// Application that processes user directives and queries
struct UserManagementApp {
directive_handler: UpdateUserEmailHandler,
query_handler: FindUserByEmailHandler,
config: AppConfig,
}
impl Application for UserManagementApp {
fn name(&self) -> &str {
"UserManagementApp"
}
fn initialize(&mut self) -> HexResult<()> {
// Load configuration
self.config.load()?;
// Initialize database connections
let db_pool = self.config.create_db_pool()?;
// Set up handlers with dependencies
self.directive_handler = UpdateUserEmailHandler {
repo: Box::new(PostgresUserRepository::new(db_pool.clone())),
};
self.query_handler = FindUserByEmailHandler {
query_repo: Box::new(PostgresUserQueryRepository::new(db_pool)),
};
Ok(())
}
fn run(&mut self) -> HexResult<()> {
// Example: Process a directive
let directive = UpdateUserEmail {
user_id: UserId::new(),
new_email: Email("newuser@example.com".to_string()),
};
directive.validate()?;
self.directive_handler.handle(directive)?;
// Example: Execute a query
let query = FindUserByEmail {
email: "newuser@example.com".to_string(),
};
let user = self.query_handler.handle(query)?;
println!("Found user: {:?}", user);
Ok(())
}
fn shutdown(&mut self) -> HexResult<()> {
// Close database connections
self.config.close_db_pool()?;
println!("Shutdown complete");
Ok(())
}
}
The Application trait integrates seamlessly with hexser's error handling:
impl Application for RobustApp {
fn name(&self) -> &str {
"RobustApp"
}
fn initialize(&mut self) -> HexResult<()> {
self.config.load()
.map_err(|e| Hexserror::infrastructure(
"Failed to load configuration"
).with_source(e))?;
Ok(())
}
fn run(&mut self) -> HexResult<()> {
// If run fails, shutdown is still called by execute()
self.process_requests()
.map_err(|e| Hexserror::application(
"Request processing failed"
).with_source(e))
}
fn shutdown(&mut self) -> HexResult<()> {
// Shutdown errors are properly propagated
self.cleanup_resources()
.map_err(|e| Hexserror::infrastructure(
"Cleanup failed"
).with_source(e))
}
}
fn main() -> HexResult<()> {
let mut app = RobustApp::new();
// execute() calls initialize(), run(), and shutdown() in sequence
// If any step fails, the error is returned
// shutdown() is always called even if run() fails
app.execute()
}
The Application trait provides several architectural benefits:
// Example: Compose multiple applications
struct MicroservicesApp {
user_service: UserManagementApp,
order_service: OrderProcessingApp,
notification_service: NotificationApp,
}
impl Application for MicroservicesApp {
fn name(&self) -> &str {
"MicroservicesApp"
}
fn initialize(&mut self) -> HexResult<()> {
self.user_service.initialize()?;
self.order_service.initialize()?;
self.notification_service.initialize()?;
Ok(())
}
fn run(&mut self) -> HexResult<()> {
// Run all services concurrently or sequentially
self.user_service.run()?;
self.order_service.run()?;
self.notification_service.run()?;
Ok(())
}
fn shutdown(&mut self) -> HexResult<()> {
// Shutdown in reverse order
self.notification_service.shutdown()?;
self.order_service.shutdown()?;
self.user_service.shutdown()?;
Ok(())
}
}
Hexagonal architecture makes testing trivial - just mock the ports!
Unit Testing Domain Logic:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_order_invariants() {
let order = Order {
id: OrderId::new(),
customer_id: CustomerId::new(),
items: vec![], // Empty!
status: OrderStatus::Pending,
};
assert!(order.check_invariants().is_err());
}
#[test]
fn test_email_validation() {
let invalid = Email("notanemail".to_string());
assert!(invalid.validate().is_err());
let valid = Email("test@example.com".to_string());
assert!(valid.validate().is_ok());
}
}
Testing with Mock Adapters:
#[derive(HexAdapter)]
struct MockUserRepository {
users: std::collections::HashMap<UserId, User>,
}
impl Repository<User> for MockUserRepository {
fn save(&mut self, user: User) -> HexResult<()> {
self.users.insert(user.id.clone(), user);
Ok(())
}
}
#[test]
fn test_create_user_handler() {
let mut repo = MockUserRepository {
users: std::collections::HashMap::new(),
};
let handler = CreateUserHandler {
repo: Box::new(repo),
};
let directive = CreateUserDirective {
email: "test@example.com".to_string(),
name: "Test User".to_string(),
};
assert!(handler.handle(directive).is_ok());
}
hexser provides rich, actionable, code-first errors with automatic source location and layering support. Prefer the new macro-based constructors and error codes over manual struct construction.
Preferred: macro + code + guidance
fn validate_order(order: &Order) -> HexResult<()> {
if order.items.is_empty() {
return Err(
hexser::hex_domain_error!(
hexser::error::codes::domain::INVARIANT_EMPTY,
"Order must contain at least one item"
)
.with_next_steps(&["Add at least one item to the order"]) // actionable guidance
.with_suggestions(&["order.add_item(item)", "order.items.push(item)"]) // quick fixes
.with_more_info("https://docs.rs/hexser/latest/hexser/error/codes/domain")
);
}
Ok(())
}
Display output (example):
E_HEX_001: Order must contain at least one item
at src/domain/order.rs:42:13
Next steps:
- Add at least one item to the order
Suggestions:
- order.add_item(item)
- order.items.push(item)
Cookbook
// Validation errors (field-aware)
return Err(hexser::error::hex_error::Hexserror::validation_field(
"Title cannot be empty",
"title",
));
// Not Found errors (resource + id)
return Err(hexser::error::hex_error::Hexserror::not_found("User", "123")
.with_next_step("Verify the ID and try again"));
// Port errors (communication issues)
let port_err = hexser::hex_port_error!(
hexser::error::codes::port::PORT_TIMEOUT,
"User service timed out"
).with_suggestion("Increase timeout or retry later");
// Adapter errors (infra failures) with source error
fn fetch_from_api(url: &str) -> HexResult<String> {
let resp = std::fs::read_to_string(url)
.map_err(|ioe| hexser::hex_adapter_error!(
hexser::error::codes::adapter::IO_FAILURE, // or API_FAILURE in real HTTP
"Failed to fetch resource"
).with_source(ioe))?;
Ok(resp)
}
🔥 Amazing Example: Layered mapping (Adapter → Port → Domain)
// Adapter layer
fn db_get_user(id: &str) -> HexResult<User> {
let conn = std::fs::read_to_string("/tmp/mock-db").map_err(|e|
hexser::hex_adapter_error!(
hexser::error::codes::adapter::DB_CONNECTION_FAILURE,
"Database unavailable"
)
.with_source(e)
.with_next_steps(&["Ensure DB is running", "Check connection string"])
)?;
// ... parse and return User or NotFound
Err(hexser::error::hex_error::Hexserror::not_found("User", id))
}
// Port layer wraps adapter failure with port context
fn port_get_user(id: &str) -> HexResult<User> {
db_get_user(id).map_err(|e|
hexser::hex_port_error!(
hexser::error::codes::port::COMMUNICATION_FAILURE,
"UserRepository failed"
).with_source(e)
)
}
// Domain layer consumes rich errors
fn ensure_user_exists(id: &str) -> HexResult<()> {
let _user = port_get_user(id)?; // `?` preserves full rich error stack
Ok(())
}
Notes
When using the serde feature to serialize errors (e.g., for API responses), source location information (file paths, line numbers, column numbers) can expose internal code structure to clients. hexser is secure by default and excludes this sensitive information from serialization unless explicitly enabled.
Environment Variable: HEXSER_INCLUDE_SOURCE_LOCATION
Control whether source location is included in serialized errors:
# Production (default, secure) - source location excluded
# No environment variable needed
# Development/Debug - include source location
export HEXSER_INCLUDE_SOURCE_LOCATION=1
# or
export HEXSER_INCLUDE_SOURCE_LOCATION=true
Example:
use hexser::prelude::*;
fn api_handler() -> Result<String, Box<dyn std::error::Error>> {
let err = hexser::hex_domain_error!(
hexser::error::codes::domain::INVARIANT_VIOLATION,
"Order must have items"
);
// Serialize for API response
let json = serde_json::to_string(&err)?;
// In production (env var not set):
// {"code":"E_HEX_001","message":"Order must have items",...}
// Source location is excluded for security
// In development (HEXSER_INCLUDE_SOURCE_LOCATION=1):
// {"code":"E_HEX_001","message":"Order must have items",
// "location":{"file":"src/api.rs","line":42,"column":10},...}
Ok(json)
}
Production Best Practice:
HEXSER_INCLUDE_SOURCE_LOCATION in production environmentsDisplay formatting for logsAffected Error Types:
DomainError, PortError, AdapterError (LayerError-based types)
ValidationError
NotFoundError
ConflictError
All errors with location fields
Part 6: Real-World Example - TODO Application Let's build a complete TODO application using hexagonal architecture. Domain Layer:
use hexser::prelude::*;
#[derive(Clone, Entity)]
struct Todo {
id: TodoId,
title: String,
description: String,
completed: bool,
}
#[derive(Clone, PartialEq, Eq, Hash)]
struct TodoId(String);
impl TodoId {
fn new() -> Self {
Self(uuid::Uuid::new_v4().to_string())
}
}
Ports Layer:
#[derive(HexPort)]
trait TodoRepository: Repository<Todo> {
fn find_active(&self) -> HexResult<Vec<Todo>>;
fn find_completed(&self) -> HexResult<Vec<Todo>>;
}
Adapters Layer:
#[derive(HexAdapter)]
struct InMemoryTodoRepository {
todos: std::sync::Mutex<Vec<Todo>>,
}
impl Repository<Todo> for InMemoryTodoRepository {
fn save(&mut self, todo: Todo) -> HexResult<()> {
let mut todos = self.todos.lock().unwrap();
if let Some(existing) = todos.iter_mut().find(|t| t.id == todo.id) {
*existing = todo;
} else {
todos.push(todo);
}
Ok(())
}
}
impl TodoRepository for InMemoryTodoRepository {
fn find_active(&self) -> HexResult<Vec<Todo>> {
let todos = self.todos.lock().unwrap();
Ok(todos.iter().filter(|t| !t.completed).cloned().collect())
}
fn find_completed(&self) -> HexResult<Vec<Todo>> {
let todos = self.todos.lock().unwrap();
Ok(todos.iter().filter(|t| t.completed).cloned().collect())
}
}
Application Layer:
#[derive(HexDirective)]
struct CreateTodoDirective {
title: String,
description: String,
}
impl CreateTodoDirective {
fn validate(&self) -> HexResult<()> {
if self.title.is_empty() {
return Err(Hexserror::validation_field("Title cannot be empty", "title"));
}
Ok(())
}
}
#[derive(HexDirectiveHandler)]
struct CreateTodoHandler {
repo: Box<dyn TodoRepository>,
}
impl CreateTodoHandler {
fn handle(&self, directive: CreateTodoDirective) -> HexResult<()> {
directive.validate()?;
let todo = Todo {
id: TodoId::new(),
title: directive.title,
description: directive.description,
completed: false,
};
self.repo.save(todo)?;
Ok(())
}
}
Event Sourcing
#[derive(HexAggregate)]
struct OrderAggregate {
id: OrderId,
uncommitted_events: Vec<Box<dyn DomainEvent>>,
}
impl OrderAggregate {
fn place_order(&mut self, items: Vec<OrderItem>) -> HexResult<()> {
// Validate
if items.is_empty() {
return Err(hexser::hex_domain_error!(
hexser::error::codes::domain::INVARIANT_EMPTY,
"Order must have items"
));
}
// Create event
let event = OrderPlaced {
order_id: self.id.clone(),
items,
timestamp: current_timestamp(),
};
// Apply event
self.apply_event(&event);
// Record event
self.uncommitted_events.push(Box::new(event));
Ok(())
}
fn apply_event(&mut self, event: &dyn DomainEvent) {
// Update state based on event
}
}
Dependency Injection
struct ApplicationContext {
user_repo: Box<dyn UserRepository>,
order_repo: Box<dyn OrderRepository>,
payment_port: Box<dyn PaymentPort>,
}
impl ApplicationContext {
fn new_production() -> Self {
Self {
user_repo: Box::new(PostgresUserRepository::new()),
order_repo: Box::new(PostgresOrderRepository::new()),
payment_port: Box::new(StripePaymentGateway::new()),
}
}
fn new_test() -> Self {
Self {
user_repo: Box::new(MockUserRepository::new()),
order_repo: Box::new(MockOrderRepository::new()),
payment_port: Box::new(MockPaymentGateway::new()),
}
}
}
hexser/
├── domain/ [Core Business Logic - No Dependencies]
│ ├── HexEntity - Identity-based objects
│ ├── HexValueItem - Value-based objects
│ ├── Aggregate - Consistency boundaries
│ ├── DomainEvent - Significant occurrences
│ └── DomainService - Cross-entity operations
│
├── ports/ [Interface Definitions]
│ ├── Repository - Persistence abstraction
│ ├── UseCase - Business operations
│ ├── Query - Read-only operations (CQRS)
│ ├── InputPort - Entry points
│ └── OutputPort - External system interfaces
│
├── adapters/ [Concrete Implementations]
│ ├── Adapter - Port implementations
│ └── Mapper - Data transformation
│
├── application/ [Orchestration Layer]
│ ├── Directive - Write operations (CQRS)
│ ├── DirectiveHandler - Directive execution
│ └── QueryHandler - Query execution
│
├── infrastructure/ [Technology Layer]
│ └── Config - Infrastructure setup
│
├── error/ [Rich Error Types]
│ └── Hexserror - Actionable errors
│
└── graph/ [Introspection - Phase 2+]
├── Layer - Architectural layers
├── Role - Component roles
├── Relationship - Component connections
└── NodeId - Unique identification
We welcome contributions! This crate follows strict coding standards:
See CONTRIBUTING.md for details.
Licensed under either of:
at your option.
Inspired by:
The hex crate includes comprehensive examples and tutorials to help you learn hexagonal architecture.
Running Examples
cargo run --example simple_todo
Looking for concrete, minimal examples you can paste into your app? Check out the Potions crate in this workspace:
Add to your project via workspace path:
[dependencies]
hexser_potions = { path = "../hexser_potions", version = "0.4.7" }
Then in code:
use hexser_potions::auth::{SignUpUser, InMemoryUserRepository, execute_signup};
When you want zero dynamic dispatch and the smallest possible runtime footprint (including on wasm32-unknown-unknown), use the new static DI utilities.
Feature flags:
static-dicontainerStatic DI provides two simple building blocks:
StaticContainer<T>: owns your fully built object graphhex_static! { ... } macro: builds the graph from a block without any dynExample:
use hexser::prelude::*;
#[derive(Clone, Debug)]
struct Repo;
#[derive(Clone, Debug)]
struct Service { repo: Repo }
let app = hexser::hex_static!({
let repo = Repo;
let service = Service { repo: repo.clone() };
(repo, service)
});
let (repo, service) = app.into_inner();
WASM guidance:
Default features are WASM-friendly (no tokio). Keep container disabled for wasm.
Use static-di (default) and avoid the dyn container for maximum compatibility.
We are migrating the repository port away from id-centric methods (find_by_id/find_all) toward a generic, filter-oriented API that better models your domain while staying storage-agnostic. The new QueryRepository trait introduces domain-owned Filter and SortKey types plus FindOptions for sorting and pagination.
Highlights:
Example:
use hexser::prelude::*;
use hexser::ports::repository::{QueryRepository, FindOptions, Sort, Direction};
#[derive(Entity, Clone, Debug)]
struct User { id: String, email: String, created_at: u64 }
// Domain-owned query types
#[derive(Clone, Debug)]
enum UserFilter {
ById(String),
ByEmail(String),
All,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UserSortKey { CreatedAt, Email }
#[derive(Default)]
struct InMemoryUserRepository { users: Vec<User> }
impl Repository<User> for InMemoryUserRepository {
fn save(&mut self, user: User) -> HexResult<()> { if let Some(i)=self.users.iter().position(|u| u.id==user.id){self.users[i]=user;} else { self.users.push(user);} Ok(()) }
}
impl QueryRepository<User> for InMemoryUserRepository {
type Filter = UserFilter;
type SortKey = UserSortKey;
fn find_one(&self, f: &Self::Filter) -> HexResult<Option<User>> {
Ok(self.users.iter().find(|u| match f { UserFilter::ById(id)=>&u.id==id, UserFilter::ByEmail(e)=>&u.email==e, UserFilter::All=>true }).cloned())
}
fn find(&self, f: &Self::Filter, opts: FindOptions<Self::SortKey>) -> HexResult<Vec<User>> {
let mut items: Vec<_> = self.users.iter().filter(|u| match f { UserFilter::ById(id)=>&u.id==id, UserFilter::ByEmail(e)=>&u.email==e, UserFilter::All=>true }).cloned().collect();
if let Some(sorts) = opts.sort {
for s in sorts.into_iter().rev() {
match (s.key, s.direction) {
(UserSortKey::CreatedAt, Direction::Asc) => items.sort_by_key(|u| u.created_at),
(UserSortKey::CreatedAt, Direction::Desc) => items.sort_by_key(|u| std::cmp::Reverse(u.created_at)),
(UserSortKey::Email, Direction::Asc) => items.sort_by(|a,b| a.email.cmp(&b.email)),
(UserSortKey::Email, Direction::Desc) => items.sort_by(|a,b| b.email.cmp(&a.email)),
}
}
}
let offset = opts.offset.unwrap_or(0) as usize;
let limit = opts.limit.map(|l| l as usize).unwrap_or_else(|| items.len().saturating_sub(offset));
let end = offset.saturating_add(limit).min(items.len());
Ok(items.into_iter().skip(offset).take(end.saturating_sub(offset)).collect())
}
}
fn main() -> HexResult<()> {
let repo = InMemoryUserRepository::default();
// Unique lookup
let _ = <InMemoryUserRepository as QueryRepository<User>>::find_one(&repo, &UserFilter::ByEmail("alice@ex.com".into()))?;
// List with pagination
let opts = FindOptions { sort: Some(vec![Sort { key: UserSortKey::CreatedAt, direction: Direction::Desc }]), limit: Some(25), offset: Some(0) };
let _page = <InMemoryUserRepository as QueryRepository<User>>::find(&repo, &UserFilter::All, opts)?;
Ok(())
}
Migration tips:
For more details, see MIGRATION_GUIDE.md and docs/core-concepts.md.
The following focused examples demonstrate the new query-first API using domain-owned Filter and SortKey types. These snippets avoid deprecated methods and illustrate common tasks.
// Given: domain types User, UserFilter::ByEmail(String)
let repo = InMemoryUserRepository::default();
let maybe_user = <InMemoryUserRepository as hexser::ports::repository::QueryRepository<User>>
::find_one(&repo, &UserFilter::ByEmail(String::from("alice@example.com")))?;
let opts = hexser::ports::repository::FindOptions {
sort: Some(vec![
hexser::ports::repository::Sort { key: UserSortKey::Email, direction: hexser::ports::repository::Direction::Asc },
hexser::ports::repository::Sort { key: UserSortKey::CreatedAt, direction: hexser::ports::repository::Direction::Desc },
]),
limit: None,
offset: None,
};
let users = <InMemoryUserRepository as hexser::ports::repository::QueryRepository<User>>::find(
&repo,
&UserFilter::All,
opts,
)?;
let opts = hexser::ports::repository::FindOptions { sort: None, limit: Some(10), offset: Some(10) };
let page = <InMemoryUserRepository as hexser::ports::repository::QueryRepository<User>>::find(&repo, &UserFilter::All, opts)?;
let exists = <InMemoryUserRepository as hexser::ports::repository::QueryRepository<User>>::exists(
&repo,
&UserFilter::ByEmail(String::from("bob@example.com")),
)?;
let total = <InMemoryUserRepository as hexser::ports::repository::QueryRepository<User>>::count(
&repo,
&UserFilter::All,
)?;
let removed = <InMemoryUserRepository as hexser::ports::repository::QueryRepository<User>>::delete_where(
&mut repo.clone(),
&UserFilter::ByEmail(String::from("bob@example.com")),
)?;
Export a machine-readable JSON describing your project's architecture for AI assistants and tooling.
Requirements:
ai feature (serde/serde_json are included automatically).Commands:
# Build and run the exporter (prints JSON to stdout)
cargo run -p hexser --features ai --bin hex-ai-export
# Save to a file
cargo run -p hexser --features ai --bin hex-ai-export --quiet > target/ai-context.json
What it does:
HexGraph from the component registryAIContext via hexser::ai::ContextBuilderNotes:
hex-ai-export is only built when the ai feature is enabled.target/ai-context.json or generate it in CI as an artifact.The exported AIContext JSON includes detailed component information:
ComponentInfo fields:
type_name: Fully qualified type namelayer: Architectural layer (Domain, Port, Adapter, Application)role: Component role (Entity, Repository, Directive, Query, etc.)module_path: Module path where component is definedpurpose: Optional description of component purposedependencies: List of component dependenciesmethods: List of public methods with detailed information (NEW)MethodInfo structure (available in methods array):
name: Method namesignature: Full method signaturedocumentation: Doc comment for the methodparameters: Array of parameter details (name, type, description)return_type: Method return typeis_public: Visibility flagis_async: Async flagCurrent Status: The methods field is included in the JSON schema and ready for use. Currently populated as an empty array; future enhancement will extract method information via rustdoc JSON output or source code parsing to provide complete API documentation to AI models.
Example ComponentInfo with methods:
{
"type_name": "UserRepository",
"layer": "Port",
"role": "Repository",
"module_path": "ports::user_repository",
"purpose": "Manages user persistence",
"methods": [
{
"name": "find_by_id",
"signature": "fn find_by_id(&self, id: &str) -> HexResult<Option<User>>",
"documentation": "Finds a user by their ID",
"parameters": [
{
"name": "id",
"param_type": "&str",
"description": "User identifier"
}
],
"return_type": "HexResult<Option<User>>",
"is_public": true,
"is_async": false
}
],
"dependencies": []
}
Export a comprehensive, single-file JSON that bundles:
Commands:
# Print Agent Pack JSON to stdout
cargo run -p hexser --features ai --bin hex-ai-pack
# Save to a file
cargo run -p hexser --features ai --bin hex-ai-pack --quiet > target/ai-pack.json
Notes:
Hexser includes a built-in MCP (Model Context Protocol) server that exposes your project's architecture to AI assistants via a standardized JSON-RPC interface. This enables AI tools like Claude Desktop, Cline, and other MCP-compatible clients to query your architecture in real-time.
🆕 New to MCP? Check out the Beginner's Walkthrough for IntelliJ + Junie for step-by-step setup instructions.
Requirements:
mcp feature (automatically includes ai, serde, and serde_json).# Run the MCP server (stdio transport)
cargo run -p hexser --features mcp --bin hex-mcp-server
# The server reads JSON-RPC requests from stdin and writes responses to stdout
The MCP server supports multi-project mode, exposing resources for multiple projects simultaneously:
Resource URI Format:
hexser://{project}/context and hexser://{project}/packhexser://context and hexser://pack (assumes project name "hexser")Resource Types:
hexser://{project}/context - Machine-readable architecture context (AIContext JSON)
hexser://{project}/pack - Comprehensive Agent Pack (all-in-one JSON)
Example Resources:
hexser://hexser/context - Architecture context for the hexser projecthexser://myapp/pack - Full agent pack for myapp projecthexser://context - Legacy format, maps to hexser://hexser/contextConfigure your AI assistant to use the MCP server:
Claude Desktop (config.json):
{
"mcpServers": {
"hexser": {
"command": "cargo",
"args": ["run", "-p", "hexser", "--features", "mcp", "--bin", "hex-mcp-server"],
"cwd": "/path/to/your/hexser/project"
}
}
}
Cline / Other MCP Clients: Follow the client-specific configuration to add the above command as an MCP server endpoint.
initialize, resources/list, resources/read, and hexser/refresh methodsProjectRegistryWhen AI agents modify project code (adding new components, changing architecture), the MCP server needs to be updated to reflect these changes. Hexser uses Rust's inventory crate which populates a static registry at compile time, so changes require recompilation.
The hexser/refresh Method:
{
"jsonrpc": "2.0",
"id": 1,
"method": "hexser/refresh",
"params": {
"project": "myproject"
}
}
Workflow:
#[derive(HexEntity)] to new struct, etc.)hexser/refresh with project namecargo build -p {project} --features macros{"status": "restart_required", "compiled": true, ...} with message that MCP server must be restarted{"status": "error", "compiled": false, "error": "..."} with compilation errorsImportant: After successful compilation, you must manually restart the MCP server to load the updated architecture graph. The inventory static cache is cleared and repopulated during the restart.
Example Response (Success):
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"status": "restart_required",
"compiled": true,
"components_added": 0,
"components_removed": 0,
"error": "Compilation successful. Server restart required to load new graph."
}
}
Example Response (Compilation Error):
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"status": "error",
"compiled": false,
"components_added": 0,
"components_removed": 0,
"error": "error[E0425]: cannot find value `foo` in this scope..."
}
}
Best Practices:
hexser/refresh after making architectural changescompiled field to verify build successThe MCP server supports serving multiple projects simultaneously using ProjectRegistry:
Default Behavior (Single Project):
By default, McpStdioServer::new() creates a registry with the current HexGraph as a single project named "hexser". This provides backward compatibility with existing configurations.
Custom Multi-Project Setup: Create a custom binary to register multiple projects:
use hexser::domain::mcp::{ProjectConfig, ProjectRegistry};
use hexser::adapters::mcp_stdio::McpStdioServer;
use hexser::graph::HexGraph;
fn main() -> hexser::HexResult<()> {
let mut registry = ProjectRegistry::new();
// Register project 1
let graph1 = HexGraph::current(); // or load from specific crate
registry.register(ProjectConfig::new(
String::from("myapp"),
std::path::PathBuf::from("/path/to/myapp"),
graph1,
));
// Register project 2
let graph2 = HexGraph::current(); // load from another crate
registry.register(ProjectConfig::new(
String::from("backend"),
std::path::PathBuf::from("/path/to/backend"),
graph2,
));
let server = McpStdioServer::with_registry(registry);
server.run()
}
Environment-Based Configuration (Future): Future versions may support configuration via environment variables or config files for dynamic project discovery.
Available APIs:
McpStdioServer::new() - Single project mode (backward compatible)McpStdioServer::with_registry(registry) - Multi-project modeMcpStdioServer::with_graph(graph) - Deprecated, use with_registry insteadNotes:
hex-mcp-server binary is only built when the mcp feature is enabled.hexser://{project}/context and hexser://{project}/pack resources.Hexser includes a complete example of a REST-based adapter using reqwest::blocking and serde_json. This adapter connects to an external weather API and maps JSON responses to domain models with robust error handling.
// Domain: Forecast value object (in hexser::domain::forecast)
pub struct Forecast {
city: String,
temperature_c: f64,
condition: String,
observed_at_iso: Option<String>,
}
// Port: WeatherPort trait (in hexser::ports::weather_port)
pub trait WeatherPort {
fn get_forecast(&self, city: &str) -> HexResult<Forecast>;
}
// Adapter: RestWeatherAdapter (self-contained in examples/weather_adapter.rs)
pub struct RestWeatherAdapter {
api_base_url: String,
client: reqwest::blocking::Client,
}
impl RestWeatherAdapter {
pub fn new(api_base_url: String) -> Self {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("Failed to build reqwest client");
Self { api_base_url, client }
}
}
impl WeatherPort for RestWeatherAdapter {
fn get_forecast(&self, city: &str) -> HexResult<Forecast> {
let url = format!("{}?city={}", self.api_base_url, city);
// HTTP call with error mapping (API_FAILURE)
let response = self.client.get(&url)
.send()
.map_err(|e| {
Hexserror::adapter(
codes::adapter::API_FAILURE,
"Failed to connect to weather API"
)
.with_source(e)
.with_next_steps(&["Verify API endpoint", "Check network"])
})?;
// Deserialize JSON with error mapping (MAPPING_FAILURE)
let api_response: ApiWeatherResponse = serde_json::from_str(&response.text()?)
.map_err(|e| {
Hexserror::adapter(
codes::adapter::MAPPING_FAILURE,
"Failed to parse JSON response"
)
.with_source(e)
})?;
// Map to domain model
Forecast::new(
api_response.city,
api_response.temp_c,
api_response.condition,
api_response.observed_at,
)
}
}
This complete example is available at examples/weather_adapter.rs. Run with:
cargo run --example weather_adapter
When integrating pre-built authentication patterns from hexser_potions, you must connect the Potion's defined Ports to your own concrete adapters for databases and session management.
// Port for user persistence
trait UserRepository: Repository<User> {
fn find_by_username(&self, username: &str) -> HexResult<Option<User>>;
fn find_by_email(&self, email: &str) -> HexResult<Option<User>>;
}
// Port for session management (new for question 4)
trait SessionPort {
fn create_session(&self, user_id: &str, ttl_secs: u64) -> HexResult<String>;
fn validate_session(&self, token: &str) -> HexResult<Option<String>>;
fn revoke_session(&self, token: &str) -> HexResult<()>;
}
// Concrete PostgreSQL adapter
struct PostgresUserRepository {
pool: sqlx::PgPool,
}
impl Repository<User> for PostgresUserRepository {
fn save(&mut self, user: User) -> HexResult<()> {
// Execute INSERT/UPDATE via sqlx
sqlx::query!("INSERT INTO users (id, username, email, password_hash) VALUES ($1, $2, $3, $4)",
user.id, user.username, user.email, user.password_hash)
.execute(&self.pool)
.await
.map_err(|e| Hexserror::adapter(codes::adapter::DB_WRITE_FAILURE, "Failed to save user")
.with_source(e))?;
Ok(())
}
}
impl UserRepository for PostgresUserRepository {
fn find_by_username(&self, username: &str) -> HexResult<Option<User>> {
sqlx::query_as!(User, "SELECT * FROM users WHERE username = $1", username)
.fetch_optional(&self.pool)
.await
.map_err(|e| Hexserror::adapter(codes::adapter::DB_READ_FAILURE, "Query failed")
.with_source(e))
}
}
// Redis-based session adapter
struct RedisSessionAdapter {
client: redis::Client,
}
impl SessionPort for RedisSessionAdapter {
fn create_session(&self, user_id: &str, ttl_secs: u64) -> HexResult<String> {
let token = uuid::Uuid::new_v4().to_string();
let mut conn = self.client.get_connection()
.map_err(|e| Hexserror::adapter(codes::adapter::CONNECTION_FAILURE, "Redis unavailable")
.with_source(e))?;
redis::cmd("SETEX")
.arg(format!("session:{}", token))
.arg(ttl_secs)
.arg(user_id)
.query(&mut conn)
.map_err(|e| Hexserror::adapter(codes::adapter::DB_WRITE_FAILURE, "Session write failed")
.with_source(e))?;
Ok(token)
}
fn validate_session(&self, token: &str) -> HexResult<Option<String>> {
let mut conn = self.client.get_connection()?;
let user_id: Option<String> = redis::cmd("GET")
.arg(format!("session:{}", token))
.query(&mut conn)
.map_err(|e| Hexserror::adapter(codes::adapter::DB_READ_FAILURE, "Session read failed")
.with_source(e))?;
Ok(user_id)
}
fn revoke_session(&self, token: &str) -> HexResult<()> {
let mut conn = self.client.get_connection()?;
redis::cmd("DEL")
.arg(format!("session:{}", token))
.query(&mut conn)
.map_err(|e| Hexserror::adapter(codes::adapter::DB_WRITE_FAILURE, "Session delete failed")
.with_source(e))?;
Ok(())
}
}
// Application context with wired adapters
struct AppContext {
user_repo: Box<dyn UserRepository>,
session_port: Box<dyn SessionPort>,
}
impl AppContext {
fn new_production(db_pool: sqlx::PgPool, redis_client: redis::Client) -> Self {
Self {
user_repo: Box::new(PostgresUserRepository { pool: db_pool }),
session_port: Box::new(RedisSessionAdapter { client: redis_client }),
}
}
}
When a directive involves multiple repository operations that must succeed or fail atomically (e.g., decrementing stock and creating an order), use a database transaction and pass it explicitly to each repository call.
// Ports accepting a transaction context
trait ProductRepository {
fn decrement_stock(&self, tx: &mut PgTransaction, product_id: &str, qty: u32) -> HexResult<()>;
}
trait OrderRepository {
fn create_order(&self, tx: &mut PgTransaction, order: Order) -> HexResult<()>;
}
trait EventBus {
fn publish(&self, event: OrderCreated) -> HexResult<()>;
}
struct ProcessOrderHandler {
product_repo: Box<dyn ProductRepository>,
order_repo: Box<dyn OrderRepository>,
event_bus: Box<dyn EventBus>,
db_pool: sqlx::PgPool,
}
impl ProcessOrderHandler {
async fn handle(&self, directive: ProcessOrderDirective) -> HexResult<()> {
// Begin transaction
let mut tx = self.db_pool.begin()
.await
.map_err(|e| Hexserror::adapter(codes::adapter::DB_CONNECTION_FAILURE, "Failed to begin transaction")
.with_source(e))?;
// 1) Decrement stock for each product (atomic within tx)
for item in &directive.items {
self.product_repo.decrement_stock(&mut tx, &item.product_id, item.quantity)
.await
.map_err(|e| {
// Rollback is automatic on error via Drop
Hexserror::domain(codes::domain::INVARIANT_VIOLATION, "Insufficient stock")
.with_source(e)
})?;
}
// 2) Create order record (atomic within tx)
let order = Order::new(directive.customer_id, directive.items)?;
self.order_repo.create_order(&mut tx, order.clone())
.await
.map_err(|e| {
Hexserror::adapter(codes::adapter::DB_WRITE_FAILURE, "Failed to create order")
.with_source(e)
})?;
// Commit transaction (all-or-nothing)
tx.commit()
.await
.map_err(|e| Hexserror::adapter(codes::adapter::DB_WRITE_FAILURE, "Transaction commit failed")
.with_source(e))?;
// 3) Dispatch event (after commit)
let event = OrderCreated { order_id: order.id.clone(), timestamp: now() };
self.event_bus.publish(event)?;
Ok(())
}
}
struct PostgresProductRepository;
impl ProductRepository for PostgresProductRepository {
async fn decrement_stock(&self, tx: &mut PgTransaction<'_>, product_id: &str, qty: u32) -> HexResult<()> {
let rows_affected = sqlx::query!(
"UPDATE products SET stock = stock - $1 WHERE id = $2 AND stock >= $1",
qty as i32, product_id
)
.execute(tx)
.await
.map_err(|e| Hexserror::adapter(codes::adapter::DB_WRITE_FAILURE, "Stock update failed")
.with_source(e))?
.rows_affected();
if rows_affected == 0 {
return Err(Hexserror::domain(codes::domain::INVARIANT_VIOLATION, "Insufficient stock or product not found"));
}
Ok(())
}
}
Key Points:
&mut PgTransaction (or equivalent) to all repository methods within the transaction.Drop trait if any error occurs before commit().When data must be fetched from multiple sources (e.g., SQL for core profile, NoSQL for preferences), implement a composite adapter that queries both, handles failures gracefully, and optionally caches results.
trait ProfileRepository {
fn find_by_id(&self, user_id: &str) -> HexResult<Profile>;
}
struct CompositeProfileRepository {
sql_db: sqlx::PgPool,
nosql_client: mongodb::Client,
cache: std::sync::Arc<std::sync::Mutex<lru::LruCache<String, Profile>>>,
}
impl CompositeProfileRepository {
fn new(sql_db: sqlx::PgPool, nosql_client: mongodb::Client, cache_size: usize) -> Self {
Self {
sql_db,
nosql_client,
cache: std::sync::Arc::new(std::sync::Mutex::new(lru::LruCache::new(cache_size))),
}
}
}
impl ProfileRepository for CompositeProfileRepository {
async fn find_by_id(&self, user_id: &str) -> HexResult<Profile> {
// Check cache first
{
let mut cache = self.cache.lock().unwrap();
if let Some(cached) = cache.get(user_id) {
return Ok(cached.clone());
}
}
// 1) Fetch core profile from SQL (primary source, must succeed)
let core_profile: SqlProfileRow = sqlx::query_as!(
SqlProfileRow,
"SELECT id, username, email, created_at FROM users WHERE id = $1",
user_id
)
.fetch_one(&self.sql_db)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => Hexserror::not_found("Profile", user_id),
_ => Hexserror::adapter(codes::adapter::DB_READ_FAILURE, "SQL query failed")
.with_source(e)
.with_next_step("Check database connectivity"),
})?;
// 2) Enrich with preferences from NoSQL (optional, degrade gracefully)
let collection = self.nosql_client.database("app").collection::<bson::Document>("user_prefs");
let prefs_result = collection.find_one(bson::doc! { "user_id": user_id }, None).await;
let preferences = match prefs_result {
Ok(Some(doc)) => {
// Parse preferences from document
Preferences::from_bson(&doc).unwrap_or_default()
}
Ok(None) => {
// User has no preferences document yet; use defaults
Preferences::default()
}
Err(e) => {
// NoSQL source failed; log warning and use defaults (degrade gracefully)
eprintln!("Warning: Failed to fetch preferences for {}: {}", user_id, e);
Preferences::default()
}
};
// 3) Combine into domain model
let profile = Profile {
id: core_profile.id,
username: core_profile.username,
email: core_profile.email,
created_at: core_profile.created_at,
preferences,
};
// 4) Cache result
{
let mut cache = self.cache.lock().unwrap();
cache.put(user_id.to_string(), profile.clone());
}
Ok(profile)
}
}
Hexserror::Adapter or Hexserror::NotFound with actionable guidance.Preferences::default()).// Example: TTL-based cache wrapper
struct TtlCache<K, V> {
cache: lru::LruCache<K, (V, std::time::Instant)>,
ttl: std::time::Duration,
}
impl<K: std::hash::Hash + Eq, V: Clone> TtlCache<K, V> {
fn get(&mut self, key: &K) -> Option<V> {
if let Some((value, inserted_at)) = self.cache.get(key) {
if inserted_at.elapsed() < self.ttl {
return Some(value.clone());
}
}
None
}
}