# Hexser - Zero-Boilerplate Hexagonal Architecture
[](https://crates.io/crates/hexser)
[](https://docs.rs/hexser)
[](https://github.com/squillo/hexser)
**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.**
---
## Table of Contents
- [Why hexser?](#why-hexser)
- [Quick Start](#quick-start)
- [Canonical, compile-tested examples](#canonical-compile-tested-examples)
- [Feature Flags](#feature-flags)
- [Complete Tutorial](#complete-tutorial)
- [CQRS Pattern with hex](#part-3-cqrs-pattern-with-hex)
- [Application Lifecycle and Entry Points](#part-35-application-lifecycle-and-entry-points)
- [Testing Your Hexagonal Application](#part-4-testing-your-hexagonal-application)
- [Error Handling](#part-5-error-handling)
- [Real-World Example - TODO Application](#part-6-real-world-example---todo-application)
- [Advanced Patterns](#advanced-patterns)
- [Knowledge Graph](#knowledge-graph)
- [Static (non-dyn) DI — WASM-friendly](#static-non-dyn-di--wasm-friendly)
- [Repository: Filter-based queries](#repository-filter-based-queries)
- [AI Context Export (CLI)](#ai-context-export-cli)
- [MCP Server (Model Context Protocol)](#-mcp-server-model-context-protocol)
- [Examples & Tutorials](#examples--tutorials)
- [Potions (copy-friendly examples)](#potions-copy-friendly-examples)
- [Contributing](#contributing)
- [License](#license)
- [Acknowledgments](#acknowledgments)
- [Additional Resources](#additional-resources)
> Tip: Press Cmd/Ctrl+F and search for “Part” to jump to tutorials.
## Why hexser?
Traditional hexagonal architecture requires significant boilerplate:
- Manual registration of components
- Explicit dependency wiring
- Repetitive trait implementations
- Complex validation logic
**hexser eliminates all of this.** Through intelligent trait design, compile-time graph construction, and rich error handling, you get:
- [x] **Zero Boilerplate** - Define your types, derive traits, done
- [x] **Type-Safe Architecture** - Compiler enforces layer boundaries
- [x] **Self-Documenting** - Graph visualization shows your architecture
- [x] **Intent Inference** - System understands itself through structure
- [x] **Rich Errors** - Helpful, actionable error messages
- [x] **Zero Runtime Overhead** - Everything happens at compile time
- [x] **AI Completion** - Expose your Rust architecture to AI agents
---
## Quick Start
Add to your `Cargo.toml`:
```toml
[dependencies]
hexser = "0.6"
```
Your First Hexagonal Application
```rust
use hexser::prelude::*;
// 1. Define your domain entity. Derive HexEntity (its `Id` is taken from the `id` field)
// and HexDomain to register it in the architecture graph. (Clone lets the adapter below
// return owned copies.)
#[derive(Clone, HexEntity, HexDomain)]
struct User {
id: String,
email: String,
name: String,
}
// 2. Define a port (interface). `Repository` is save-only; reads live on `QueryRepository`.
// Note: derives apply to structs/enums, not traits — do not put a derive on the trait.
trait UserRepository: Repository<User> {
fn find_by_email(&self, email: &str) -> HexResult<Option<User>>;
}
// 3. Implement an adapter. Deriving HexAdapter registers it and implements the `Adapter`
// marker trait for you.
#[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: String::from("1"),
email: String::from("alice@example.com"),
name: String::from("Alice"),
};
repo.save(user)?;
let found = repo.find_by_email("alice@example.com")?;
assert_eq!(found.map(|u| u.name), Some(String::from("Alice")));
Ok(())
}
```
That's it! You've just built a hexagonal architecture application with:
- Clear layer separation
- Type-safe interfaces
- Testable components
- Swappable implementations
---
## Canonical, compile-tested examples
This README contains a lot of hand-written cookbook snippets. Most are self-contained, but a
few (particularly the deep-dive sections further down) reference application types that aren't
fully spelled out inline, for brevity. If a snippet here ever disagrees with the API, **the
compiled source below is authoritative** — treat it as the source of truth over any doc drift:
- **`examples/*.rs`** — built by CI on every PR (`cargo build -p hexser --all-features --examples`), so they cannot silently drift from the real API. Start with:
- `examples/simple_todo.rs` — minimal end-to-end app: entity, adapter, `Repository` + `QueryRepository`
- `examples/cqrs_pattern.rs` — `Directive`/`DirectiveHandler` and `Query`/`QueryHandler` implemented by hand (there are no `HexDirectiveHandler`/`HexQueryHandler` derives)
- `examples/weather_adapter.rs` — a REST adapter with full `Hexserror` error-mapping (`API_FAILURE`, `MAPPING_FAILURE`)
- `examples/transactional_order.rs` — a directive spanning multiple repository writes
- `examples/realworld_api/` — a full, separately-tested workspace member; its own `cargo test` run (from `hexser/examples/realworld_api`) is a dedicated CI job
- `examples/tutorial_01_hello.rs` through `tutorial_04_application.rs` (plus `tutorial_01_entity.rs`, `tutorial_02_repository.rs`) — the step-by-step tutorial code, also compiled in CI
- **`tutorials/`** — narrated, step-by-step lessons (`01-hello-hex` through `05-graph-analysis`) that walk through the same concepts as the `tutorial_*` examples above with more explanation. Each lesson is its own small crate outside the workspace; unlike `examples/*.rs`, these are not currently part of the CI build matrix, so prefer the `examples/` versions if you ever see the two disagree.
Run any example from the `hexser/` directory with `cargo run --example <name>` (see
[Examples & Tutorials](#-examples--tutorials) below for more).
---
## Feature Flags
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.
### Available Features
#### `default = ["macros", "static-di"]`
Enabled by default. Includes procedural macros and zero-cost static dependency injection.
```toml
[dependencies]
hexser = "0.6" # Uses default features
```
#### `macros`
Enables procedural macros for deriving hexagonal architecture traits.
**Provides:**
- `#[derive(HexEntity)]` - Implement HexEntity trait for domain entities (`Id` is taken from the struct's `id` field)
- `#[derive(HexDomain)]` - Register a domain-layer type in the architecture graph (role defaults to `Entity`; override with `#[hex(role = "ValueObject")]`)
- `#[derive(HexValueItem)]` - Implement HexValueItem trait with default validation (override validate() for custom logic)
- `#[derive(HexAggregate)]` - Implement `Aggregate` with a default no-op `check_invariants` (override for real invariants); requires the type to also implement `HexEntity`
- `#[derive(HexPort)]` - Register a port-layer component (struct/enum; role defaults to `Repository`). Derives target structs/enums, not traits — apply it to a marker struct alongside your port trait.
- `#[derive(HexAdapter)]` - Mark adapter implementations (implements the `Adapter` marker trait and registers the type)
- `#[derive(HexRepository)]` - Semantic marker for repository ports; pair with `#[derive(HexPort)]` (adds no trait impl or registration of its own)
- `#[derive(HexDirective)]` - Implement `Directive` with a default no-op `validate` (`Ok(())`) and register the type (role defaults to `Directive`; override with `#[hex(role = "...")]`); implement `Directive` manually instead if you need real validation logic
- `#[derive(HexQuery)]` - Register a query type in the architecture graph (role defaults to `Query`; override with `#[hex(role = "...")]` — registration-only, so it doubles as the derive for any Application-layer marker)
All five registration derives honour `#[hex(role = "...")]`, and all five REFUSE a generic target with a compile error naming the marker-struct remedy: `inventory` cannot name a single honest node for `Foo<T>`, so skipping the submission in silence would leave a type that implements `Registrable` and is in no graph. Registering without a derive (`hexser::hex_register_domain!` and friends) emits the same impl + submission pair.
**Dependencies:** `hexser_macros`
```toml
[dependencies]
hexser = { version = "0.6", default-features = false, features = ["macros"] }
```
#### `static-di`
Zero-cost, WASM-friendly static dependency injection. No runtime overhead, no dynamic dispatch.
**Provides:**
- `StaticContainer` for compile-time dependency resolution
- Type-safe service registration without `dyn`
- Full WASM compatibility
**Dependencies:** None (zero-cost abstraction)
```toml
[dependencies]
hexser = { version = "0.6", features = ["static-di"] }
```
**Example** (see the `hex_static!` macro and `StaticContainer<T>` for the real API):
```rust
use hexser::static_di::StaticContainer;
// StaticContainer<T> wraps a single value with zero runtime overhead.
let container = StaticContainer::new(String::from("my-service"));
let service: &String = container.get();
assert_eq!(service, "my-service");
```
#### `ai`
Enables AI context export functionality for exposing architecture metadata to AI agents.
**Provides:**
- `AIContext` type with architecture metadata
- `AgentPack` for packaging context
- JSON serialization of graph data
- CLI tools: `hex-ai-export`, `hex-ai-pack`
- **Method-level documentation**: ComponentInfo includes a `methods` field capturing method signatures, parameters, return types, and documentation
**Dependencies:** `serde`, `serde_json`
```toml
[dependencies]
hexser = { version = "0.6", features = ["ai"] }
```
**Usage:**
```bash
# 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
```
#### `mcp`
Model Context Protocol server implementation for serving architecture data via JSON-RPC.
**Provides:**
- MCP server over stdio transport
- Project-scoped resources: `hexser://{project}/context`, `hexser://{project}/pack` (the flat
`hexser://context` / `hexser://pack` forms are still accepted for backward compatibility)
- JSON-RPC 2.0 interface (including id-less notifications)
- CLI tool: `hex-mcp-server`
**Dependencies:** Requires `ai` feature, plus `serde`, `serde_json`
```toml
[dependencies]
hexser = { version = "0.6", features = ["mcp"] }
```
**Usage:**
```bash
# Start MCP server (communicates via stdin/stdout)
cargo run --bin hex-mcp-server
```
#### `async`
Enables `tokio` and `async-trait` for downstream code that implements async ports and
adapters. This feature is a **dependency toggle**: it does not currently add any async trait to
`hexser` itself — you define async methods on your own traits/adapters and use these
dependencies. (Dedicated async port traits are planned; see the issue tracker.)
**Dependencies:** `tokio`, `async-trait`
```toml
[dependencies]
hexser = { version = "0.6", features = ["async"] }
```
#### `visualization`
Enables graph visualization and export capabilities.
**Provides:**
- Graph serialization to JSON
- DOT format export for Graphviz
- Architecture diagram generation
**Dependencies:** `serde`, `serde_json`
```toml
[dependencies]
hexser = { version = "0.6", features = ["visualization"] }
```
#### `container`
Dynamic dependency injection container with async support. **Not enabled by default** to maintain WASM compatibility.
**Provides:**
- `Container` with runtime (async) service resolution
- Async service factories (`Provider` / `AsyncProvider`)
- Dynamic dispatch with `dyn` traits
**Dependencies:** `tokio`, `async-trait`
**Note:** Use `static-di` instead if you need WASM compatibility or want zero runtime overhead.
```toml
[dependencies]
hexser = { version = "0.6", features = ["container"] }
```
#### `full`
Enables all features: `ai`, `mcp`, `async`, `macros`, `visualization`, `container`, and `static-di`.
**Use for:** Development, full-featured applications, or when you need all capabilities.
```toml
[dependencies]
hexser = { version = "0.6", features = ["full"] }
```
### Binary Targets
Hexser includes three command-line tools that require specific features:
#### `hex-ai-export`
Exports architecture context as JSON for AI consumption.
**Required feature:** `ai`
```bash
cargo run --bin hex-ai-export --features ai > context.json
```
#### `hex-ai-pack`
Creates a complete agent pack with architecture metadata.
**Required feature:** `ai`
```bash
cargo run --bin hex-ai-pack --features ai --output pack.json
```
#### `hex-mcp-server`
Runs an MCP (Model Context Protocol) server over stdio.
**Required feature:** `mcp`
```bash
cargo run --bin hex-mcp-server --features mcp
```
### Feature Combinations
#### Minimal (no default features)
```toml
[dependencies]
hexser = { version = "0.6", default-features = false }
```
#### WASM-optimized
```toml
[dependencies]
hexser = { version = "0.6", default-features = false, features = ["macros", "static-di"] }
```
Runs on `wasm32-unknown-unknown` and `wasm32-wasip1` (both executed under wasmtime in CI).
Richer feature sets up to `full` compile for wasm too; see [WASM guidance](#%EF%B8%8F-static-non-dyn-di--wasm-friendly).
#### AI-enabled with async
```toml
[dependencies]
hexser = { version = "0.6", features = ["ai", "async", "visualization"] }
```
#### Full development setup
```toml
[dependencies]
hexser = { version = "0.6", features = ["full"] }
```
---
## Complete Tutorial
### Part 1: Understanding Hexagonal Architecture
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
### Part 2: The Five Layers
1. Domain Layer - Your Business Logic
The domain layer contains your core business logic, completely independent of frameworks or infrastructure.
Entities - Things with identity:
```rust
use hexser::prelude::*;
#[derive(HexEntity)]
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::error::hex_error::Hexserror::domain(
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:
```rust
#[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:
```rust
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:
```rust
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()
}
}
```
2. Ports Layer - Your Interfaces
Ports define the contracts between your domain and the outside world.
Repositories - Persistence abstraction:
```rust
// Note: derives apply to structs/enums, not traits — do not put a derive on the trait.
// To register this port in the architecture graph, derive on a marker struct instead:
// `#[derive(HexPort, HexRepository)] struct OrderRepositoryPort;`
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:
```rust
// Note: derives apply to structs/enums, not traits — do not put a derive on the trait.
// Register with a marker struct: `#[derive(HexPort)] struct PlaceOrderPort;`
trait PlaceOrder: UseCase<PlaceOrderInput, PlaceOrderOutput> {}
struct PlaceOrderInput {
customer_id: CustomerId,
items: Vec<OrderItem>,
}
struct PlaceOrderOutput {
order_id: OrderId,
}
```
Queries - Read operations (CQRS):
```rust
// Note: derives apply to structs/enums, not traits — do not put a derive on the trait.
// Register with a marker struct: `#[derive(HexPort)] struct OrderHistoryPort;`
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,
}
```
3. Adapters Layer - Your Implementations
Adapters implement ports using specific technologies.
Database Adapter:
```rust
#[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:
```rust
#[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:
```rust
#[derive(HexAdapter)]
struct OrderMapper;
impl Mapper<Order, DbOrderRow> for OrderMapper {
fn map(&self, order: Order) -> HexResult<DbOrderRow> {
// No `From<serde_json::Error> for Hexserror` exists, so map the error explicitly
// rather than relying on a bare `?`.
let items_json = serde_json::to_string(&order.items).map_err(|e| {
Hexserror::adapter(hexser::error::codes::adapter::MAPPING_FAILURE, "Failed to serialize items")
.with_source(e)
})?;
Ok(DbOrderRow {
id: order.id.to_string(),
customer_id: order.customer_id.to_string(),
items_json,
status: order.status.to_string(),
})
}
}
```
4. Application Layer - Your Orchestration
The application layer coordinates domain logic and ports.
Directive (Write Operation):
```rust
// Note: `#[derive(HexDirective)]` implements `Directive::validate` as an always-`Ok(())`
// no-op and registers the type in the graph. When you need real validation logic, implement
// `Directive` directly instead (an inherent `fn validate` of the same name would silently
// shadow the derived trait method for direct calls, while trait-object dispatch would still
// see the no-op) — see `examples/cqrs_pattern.rs` for the canonical pattern.
struct PlaceOrderDirective {
customer_id: CustomerId,
items: Vec<OrderItem>,
}
impl Directive for PlaceOrderDirective {
fn validate(&self) -> HexResult<()> {
if self.items.is_empty() {
return Err(Hexserror::validation("Items cannot be empty"));
}
Ok(())
}
}
```
Directive Handler:
There is no `HexDirectiveHandler` derive — `DirectiveHandler<D>` is a plain trait (see
`hexser::application::DirectiveHandler`); implement it directly, as `examples/cqrs_pattern.rs` does:
```rust
struct PlaceOrderHandler {
order_repo: Box<dyn OrderRepository>,
payment_port: Box<dyn PaymentPort>,
}
impl DirectiveHandler<PlaceOrderDirective> for 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:
Likewise, there is no `HexQueryHandler` derive — implement `QueryHandler<Q, R>` directly:
```rust
struct OrderHistoryHandler {
query_repo: Box<dyn OrderQueryRepository>,
}
impl QueryHandler<OrderHistoryParams, Vec<OrderView>> for OrderHistoryHandler {
fn handle(&self, params: OrderHistoryParams) -> HexResult<Vec<OrderView>> {
self.query_repo.get_order_history(
¶ms.customer_id,
params.from_date,
params.to_date
)
}
}
```
5. Infrastructure Layer - Your Technology
Infrastructure provides the concrete technology implementations.
`Config` is an empty marker trait (`hexser::infrastructure::Config`) with no derive macro; implement it directly:
```rust
struct DatabaseConfig {
connection_string: String,
pool_size: u32,
}
impl Config for DatabaseConfig {}
impl DatabaseConfig {
fn create_pool(&self) -> PgPool {
// Create database connection pool
todo!()
}
}
```
### Part 3: CQRS Pattern with hex
hexser supports Command Query Responsibility Segregation (CQRS) out of the box.
Write Side (Directives):
```rust
// Directive represents intent to change state.
// Note: `#[derive(HexDirective)]` implements `Directive::validate` as an always-`Ok(())`
// no-op; since real validation is needed here, implement `Directive` directly instead of
// deriving it (an inherent `fn validate` of the same name would silently shadow the derived
// trait method) — see `examples/cqrs_pattern.rs` for the canonical pattern.
struct UpdateUserEmail {
user_id: UserId,
new_email: Email,
}
impl Directive for UpdateUserEmail {
fn validate(&self) -> HexResult<()> {
self.new_email.validate()
}
}
// Handler executes the directive. There is no `HexDirectiveHandler` derive —
// `DirectiveHandler<D>` is a plain trait; implement it directly.
struct UpdateUserEmailHandler {
repo: Box<dyn UserRepository>,
}
impl DirectiveHandler<UpdateUserEmail> for 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):
```rust
// Query represents read operation
#[derive(HexQuery)]
struct FindUserByEmail {
email: String,
}
// Handler executes the query. There is no `HexQueryHandler` derive —
// `QueryHandler<Q, R>` is a plain trait; implement it directly.
struct FindUserByEmailHandler {
query_repo: Box<dyn UserQueryRepository>,
}
impl QueryHandler<FindUserByEmail, Option<UserView>> for FindUserByEmailHandler {
fn handle(&self, query: FindUserByEmail)
-> HexResult<Option<UserView>> {
self.query_repo.find_by_email(&query.email)
}
}
```
### Part 3.5: Application Lifecycle and Entry Points
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.
#### The Application Trait
```rust
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()
}
```
#### Minimal Application
The Application trait follows hexser's zero-boilerplate philosophy. All lifecycle methods have default implementations, so you only override what you need:
```rust
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
}
```
#### Application with CQRS Integration
Here's a complete example showing how the Application trait coordinates Directives and Queries:
```rust
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(())
}
}
```
#### Error Handling in Applications
The Application trait integrates seamlessly with hexser's error handling:
```rust
impl Application for RobustApp {
fn name(&self) -> &str {
"RobustApp"
}
fn initialize(&mut self) -> HexResult<()> {
// There is no `Hexserror::infrastructure` constructor — only
// domain/port/adapter/validation/not_found/conflict exist. Infrastructure-level
// failures (config, external resources) map to the adapter layer.
self.config.load()
.map_err(|e| Hexserror::adapter(
hexser::error::codes::io::IO_FAILURE,
"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::adapter(
hexser::error::codes::adapter::API_FAILURE,
&format!("Request processing failed: {}", e),
)
})
}
fn shutdown(&mut self) -> HexResult<()> {
// Shutdown errors are properly propagated
self.cleanup_resources().map_err(|e| {
Hexserror::adapter(
hexser::error::codes::adapter::CONNECTION_FAILURE,
&format!("Cleanup failed: {}", 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()
}
```
#### Architecture Benefits
The Application trait provides several architectural benefits:
1. **Clear Entry Points**: Marks the top level of your application, making architecture explicit
2. **Lifecycle Management**: Standardizes initialization, execution, and cleanup patterns
3. **Error Propagation**: Ensures errors during any lifecycle phase are properly handled
4. **Testability**: Easy to test each lifecycle phase independently
5. **Composability**: Applications can compose other applications for microservices or modular systems
```rust
// 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(())
}
}
```
### Part 4: Testing Your Hexagonal Application
Hexagonal architecture makes testing trivial - just mock the ports!
Unit Testing Domain Logic:
```rust
#[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:
```rust
#[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());
}
```
### Part 5: Error Handling
hexser provides rich, actionable, code-first errors with automatic source location and layering support. Prefer the `Hexserror::{domain,port,adapter,validation,not_found,conflict}` constructor functions and error codes over manual struct construction.
Preferred: constructor + code + guidance
```rust
fn validate_order(order: &Order) -> HexResult<()> {
if order.items.is_empty() {
return Err(
hexser::error::hex_error::Hexserror::domain(
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
);
}
Ok(())
}
```
Display output (example):
```text
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
```rust
// 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::error::hex_error::Hexserror::port(
hexser::error::codes::port::PORT_TIMEOUT,
"User service timed out"
).with_suggestion("Increase timeout or retry later");
// Adapter errors (infra failures). `.with_source(e)` attaches the underlying cause directly
// on the Hexserror (no need to construct the layer error struct by hand for this).
fn fetch_from_api(url: &str) -> HexResult<String> {
let resp = std::fs::read_to_string(url)
.map_err(|ioe| hexser::error::hex_error::Hexserror::adapter(
hexser::error::codes::io::IO_FAILURE, // or codes::adapter::API_FAILURE for real HTTP
"Failed to fetch resource",
).with_source(ioe))?;
Ok(resp)
}
```
🔥 Layered mapping (Adapter → Port → Domain)
```rust
// Adapter layer
fn db_get_user(id: &str) -> HexResult<User> {
let _conn = std::fs::read_to_string("/tmp/mock-db").map_err(|e|
hexser::error::hex_error::Hexserror::adapter(
hexser::error::codes::adapter::DB_CONNECTION_FAILURE,
&format!("Database unavailable: {}", 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::error::hex_error::Hexserror::port(
hexser::error::codes::port::COMMUNICATION_FAILURE,
&format!("UserRepository failed: {}", e),
)
)
}
// Domain layer consumes rich errors
fn ensure_user_exists(id: &str) -> HexResult<()> {
let _user = port_get_user(id)?; // `?` preserves the rich error
Ok(())
}
```
Notes
- All hexser errors implement std::error::Error and expose code, message, next_steps, suggestions, and location.
- Build errors with the `Hexserror::{domain,port,adapter,validation,not_found,conflict}` constructors and the `.with_next_step(s)` / `.with_suggestion(s)` builders, using constants from `hexser::error::codes::*`. These builders work on every variant (Domain/Port/Adapter/Validation/NotFound/Conflict).
- `.with_source(err)` is also available directly on `Hexserror` and forwards to the wrapped Domain/Port/Adapter error (Validation/NotFound/Conflict have no source field and are returned unchanged).
- `with_more_info(url)` and `with_location(..)` are only exposed on the layer error structs themselves (`DomainError`/`PortError`/`AdapterError`, via the `RichError` trait), not on the `Hexserror` enum. If you need a documentation URL, construct the layer struct directly and wrap it, e.g. `Hexserror::Domain(Box::new(hexser::error::domain_error::DomainError::new(code, msg).with_more_info(url)))`.
#### Security: Controlling Source Location in Serialized Errors
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:
```bash
# 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:**
```rust
use hexser::prelude::*;
fn api_handler() -> Result<String, Box<dyn std::error::Error>> {
let err = hexser::error::hex_error::Hexserror::domain(
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:**
- Never set `HEXSER_INCLUDE_SOURCE_LOCATION` in production environments
- Source location is still captured and available via `Display` formatting for logs
- Only serialization (JSON/API responses) is affected by this setting
**Affected 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:
```rust
use hexser::prelude::*;
#[derive(Clone, HexEntity)]
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:
```rust
// Note: derives apply to structs/enums, not traits — do not put a derive on the trait.
// To register this port in the architecture graph, derive on a marker struct instead:
// `#[derive(HexPort, HexRepository)] struct TodoRepositoryPort;`
trait TodoRepository: Repository<Todo> {
fn find_active(&self) -> HexResult<Vec<Todo>>;
fn find_completed(&self) -> HexResult<Vec<Todo>>;
}
```
Adapters Layer:
```rust
#[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:
```rust
// Note: `#[derive(HexDirective)]` implements `Directive::validate` as an always-`Ok(())`
// no-op; since real validation is needed here, implement `Directive` directly instead of
// deriving it (an inherent `fn validate` of the same name would silently shadow the derived
// trait method) — see `examples/cqrs_pattern.rs` for the canonical pattern.
struct CreateTodoDirective {
title: String,
description: String,
}
impl Directive for CreateTodoDirective {
fn validate(&self) -> HexResult<()> {
if self.title.is_empty() {
return Err(Hexserror::validation_field("Title cannot be empty", "title"));
}
Ok(())
}
}
// There is no `HexDirectiveHandler` derive — `DirectiveHandler<D>` is a plain trait;
// implement it directly.
struct CreateTodoHandler {
repo: Box<dyn TodoRepository>,
}
impl DirectiveHandler<CreateTodoDirective> for 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(())
}
}
```
---
## 🎓 Advanced Patterns
Event Sourcing
```rust
// `Aggregate` requires `HexEntity` as a supertrait, so both must be derived (or implemented)
// together — `#[derive(HexAggregate)]` alone does not imply `HexEntity`.
#[derive(HexEntity, 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::error::hex_error::Hexserror::domain(
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
```rust
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()),
}
}
}
```
## 📊 Knowledge Graph
```
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]
├── Layer - Architectural layers
├── Role - Component roles
├── Relationship - Component connections
└── NodeId - Unique identification
```
## 💡 Design Philosophy
- "Language of the Language": Use Rust's type system to express architecture
- Zero Boilerplate: Derive everything, configure nothing
- Compile-Time Guarantees: Catch errors before runtime
- Rich Errors: Every error is helpful and actionable
- Self-Documenting: Graph reveals architecture automatically
- Testability First: Mock anything, test everything
## 🤝 Contributing
We welcome contributions! This crate follows strict coding standards:
- One item per file: Each file contains one logical item
- No imports: Fully qualified paths (except std prelude)
- Documentation: Every item has //! and /// docs
- In-file tests: Tests live with the code they test
- No unsafe: Safe Rust only
- Rust 2024: Latest edition
See CONTRIBUTING.md for details.
## 📄 License
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
## 🙏 Acknowledgments
Inspired by:
- CEQRS by Scott Wyatt
- N Lang by Scott Wyatt
- Domain-Driven Design by Eric Evans
- Hexagonal Architecture by Alistair Cockburn
- Clean Architecture by Robert C. Martin
- Rust's type system and error handling
- The Rust community's commitment to excellence
## 📚 Additional Resources
- Hexagonal Architecture Explained
- Domain-Driven Design
- CQRS Pattern
- Ports and Adapters
## 🎯 Examples & Tutorials
The hex crate includes comprehensive examples and tutorials to help you learn hexagonal architecture.
Running Examples
```bash
cargo run --example simple_todo
```
---
## 🧪 Potions (copy-friendly examples)
Looking for concrete, minimal examples you can paste into your app?
Check out the Potions crate in this workspace:
- Path: ./hexser_potions
- Crate: hexser_potions
- Focus: small, mixable examples (auth signup, CRUD, etc.)
Add to your project via workspace path:
```toml
[dependencies]
hexser_potions = { path = "../hexser_potions", version = "0.6" }
```
Then in code:
```rust
use hexser_potions::auth::{SignUpUser, InMemoryUserRepository, execute_signup};
```
---
## ⚙️ Static (non-dyn) DI — WASM-friendly
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:
- Enabled by default: `static-di`
- Opt-in for dyn container (tokio-based): `container`
Static DI provides two simple building blocks:
- `StaticContainer<T>`: owns your fully built object graph
- `hex_static! { ... }` macro: builds the graph from a block without any `dyn`
Example:
```rust,ignore
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.
- Both `wasm32-unknown-unknown` and `wasm32-wasip1` are verified by **executing** hexser under
wasmtime in CI, not merely compiling it (`scripts/wasm-e2e.sh`). Compiling is not evidence:
hexser 0.5.0 built clean for `wasm32-unknown-unknown` and then trapped on the first
`HexGraph::current()`. Derive registration through `inventory` works on both targets.
- `wasm32-unknown-unknown` has no clock behind `std`, so `GraphMetadata::created_at` is `0` and
`ai` timestamps are the Unix epoch. Both are informational metadata. WASI (`wasm32-wasip1`)
has a real clock and reports real times.
- Host-only surfaces return errors under wasm instead of writing: `save_visualization` goes
through `std::fs`, so use `to_dot()` / `to_mermaid()` / `to_json()` and hand the string to
the host instead.
---
## Repository: Filter-based queries
As of v0.4, the repository port moved 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 `QueryRepository` trait introduces domain-owned `Filter` and `SortKey`
types plus `FindOptions` for sorting and pagination; `Repository<T>` itself is now save-only.
Highlights:
- Define small Filter and SortKey enums/structs in your domain
- Use find_one for unique lookups and find for lists with sorting/pagination
- The old id-centric methods (`find_by_id`/`find_all`) were removed in v0.4, not deprecated —
use the `QueryRepository` methods below instead (see Migration tips)
Example:
```rust
use hexser::prelude::*;
use hexser::ports::repository::{QueryRepository, FindOptions, Sort, Direction};
#[derive(HexEntity, 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 }
// Clone is needed because the "6) Delete by filter" example below calls `.clone()` to avoid
// taking `repo` by exclusive reference in a doc snippet that only has a shared `&repo` in scope.
#[derive(Default, Clone)]
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:
- find_by_id(id) -> find_one(&Filter::ById(id))
- find_all() -> find(&Filter::All, FindOptions::default())
- Add sorting/pagination via FindOptions { sort, limit, offset }
For more details, see MIGRATION_GUIDE.md and docs/src/core-concepts.md.
### v0.4 QueryRepository Examples (5+)
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.
1) Unique lookup with find_one
```rust
// 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")))?;
```
2) Listing with multi-key sorting (Email asc, CreatedAt desc)
```rust
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,
)?;
```
3) Pagination (page size 10, second page)
```rust
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)?;
```
4) Existence check
```rust
let exists = <InMemoryUserRepository as hexser::ports::repository::QueryRepository<User>>::exists(
&repo,
&UserFilter::ByEmail(String::from("bob@example.com")),
)?;
```
5) Count matching entities
```rust
let total = <InMemoryUserRepository as hexser::ports::repository::QueryRepository<User>>::count(
&repo,
&UserFilter::All,
)?;
```
6) Delete by filter (returns removed count)
```rust
let removed = <InMemoryUserRepository as hexser::ports::repository::QueryRepository<User>>::delete_where(
&mut repo.clone(),
&UserFilter::ByEmail(String::from("bob@example.com")),
)?;
```
---
## 🤖 AI Context Export (CLI)
Export a machine-readable JSON describing your project's architecture for AI assistants and tooling.
Requirements:
- Enable the `ai` feature (serde/serde_json are included automatically).
Commands:
```sh
# 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:
- Builds the current `HexGraph` from the component registry
- Generates an `AIContext` via `hexser::ai::ContextBuilder`
- Serializes to JSON with a stable field order
Notes:
- The binary `hex-ai-export` is only built when the `ai` feature is enabled.
- For reproducible diffs, commit `target/ai-context.json` or generate it in CI as an artifact.
### 📋 AIContext Structure
The exported `AIContext` JSON includes detailed component information:
**ComponentInfo fields:**
- `type_name`: Fully qualified type name
- `layer`: Architectural layer (Domain, Port, Adapter, Application)
- `role`: Component role (Entity, Repository, Directive, Query, etc.)
- `module_path`: Module path where component is defined
- `purpose`: Optional description of component purpose
- `dependencies`: List of component dependencies
- **`methods`**: List of public methods with detailed information (**NEW**)
**MethodInfo structure** (available in `methods` array):
- `name`: Method name
- `signature`: Full method signature
- `documentation`: Doc comment for the method
- `parameters`: Array of parameter details (name, type, description)
- `return_type`: Method return type
- `is_public`: Visibility flag
- `is_async`: Async flag
**Current Status:** The `methods` field is populated with hardcoded signatures for the core
hexser traits (`Repository`/`QueryRepository`, `Directive`, `Query`), keyed off the component's
`role`; components with other roles currently get an empty array. Full extraction via rustdoc
JSON output or source code parsing (to cover arbitrary user-defined trait methods) is planned.
**Example ComponentInfo with methods:**
```json
{
"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": []
}
```
### 🧠 AI Agent Pack (All-in-One)
Export a comprehensive, single-file JSON that bundles:
- AIContext (machine-readable architecture)
- Guidelines snapshot (rules enforced for agents)
- Embedded key docs (README, ERROR_GUIDE, and local AI/guideline prompts when present)
Commands:
```sh
# 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:
- Missing optional docs are skipped gracefully. The pack remains valid JSON.
- Use this artifact as the single source of truth for external AIs and tools when proposing changes.
---
## 🔌 MCP Server (Model Context Protocol)
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](docs/MCP_BEGINNER_WALKTHROUGH.md) for step-by-step setup instructions.
Requirements:
- Enable the `mcp` feature (automatically includes `ai`, `serde`, and `serde_json`).
### Running the MCP Server
```sh
# 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
```
### Available MCP Resources
The MCP server supports **multi-project mode**, exposing resources for multiple projects simultaneously:
**Resource URI Format:**
- **New (multi-project):** `hexser://{project}/context` and `hexser://{project}/pack`
- **Legacy (backward compatible):** `hexser://context` and `hexser://pack` (assumes project name "hexser")
**Resource Types:**
1. **`hexser://{project}/context`** - Machine-readable architecture context (AIContext JSON)
- Current component graph for the specified project
- Layer relationships
- Architectural constraints
- Validation rules
2. **`hexser://{project}/pack`** - Comprehensive Agent Pack (all-in-one JSON)
- AIContext (architecture)
- Guidelines snapshot (coding rules)
- Embedded documentation (README, ERROR_GUIDE, etc.)
**Example Resources:**
- `hexser://hexser/context` - Architecture context for the hexser project
- `hexser://myapp/pack` - Full agent pack for myapp project
- `hexser://context` - Legacy format, maps to `hexser://hexser/context`
### Integration with AI Assistants
Configure your AI assistant to use the MCP server:
**Claude Desktop (config.json):**
```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.
### What the MCP Server Does
- Accepts JSON-RPC 2.0 requests via stdin
- Implements the Model Context Protocol specification
- Provides `initialize`, `resources/list`, `resources/read`, and `hexser/refresh` methods
- Serves architecture data from multiple projects via `ProjectRegistry`
- Enables AI assistants to understand your project structure in real-time
### Refreshing Architecture After Code Changes
When 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:**
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "hexser/refresh",
"params": {
"project": "myproject"
}
}
```
**Workflow:**
1. AI agent makes code changes (adds `#[derive(HexEntity)]` to new struct, etc.)
2. AI agent calls `hexser/refresh` with project name
3. MCP server triggers `cargo build -p {project} --features macros`
4. Server returns compilation result:
- **Success**: Returns `{"status": "restart_required", "compiled": true, ...}` with message that MCP server must be restarted
- **Error**: Returns `{"status": "error", "compiled": false, "error": "..."}` with compilation errors
**Important:** 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):**
```json
{
"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):**
```json
{
"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:**
- Call `hexser/refresh` after making architectural changes
- Check the `compiled` field to verify build success
- Restart your MCP client or server process after successful refresh
- Handle compilation errors gracefully in your AI workflow
### Multi-Project Configuration
The 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:
```rust
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 mode
- `McpStdioServer::with_graph(graph)` - Deprecated, use `with_registry` instead
Notes:
- The `hex-mcp-server` binary is only built when the `mcp` feature is enabled.
- The server uses stdio transport (line-delimited JSON-RPC messages).
- Each project in the registry gets its own `hexser://{project}/context` and `hexser://{project}/pack` resources.
- For production use, consider wrapping in a process manager or systemd service.
---
## 🌦 REST Adapter Example: WeatherPort
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 Model
```rust
// Domain: Forecast value object. This is an application-defined type, not part of hexser
// itself — hexser has no `domain::forecast` module. See `examples/weather_adapter.rs` for
// the full, self-contained, compiled version (including the `new` constructor and getters).
pub struct Forecast {
city: String,
temperature_c: f64,
condition: String,
observed_at_iso: Option<String>,
}
```
### Port Definition
```rust
// Port: WeatherPort trait. Also application-defined — hexser has no `ports::weather_port`
// module; this is a plain trait you define in your own crate.
pub trait WeatherPort {
fn get_forecast(&self, city: &str) -> HexResult<Forecast>;
}
```
### Adapter Implementation
```rust
// 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"])
})?;
// Read the response body, mapping any error (no `From<reqwest::Error> for Hexserror`
// exists, so `?` alone won't compile here — map_err explicitly, as above).
let body = response.text().map_err(|e| {
Hexserror::adapter(codes::adapter::MAPPING_FAILURE, "Failed to read response body")
.with_source(e)
})?;
// Deserialize JSON with error mapping (MAPPING_FAILURE)
let api_response: ApiWeatherResponse = serde_json::from_str(&body)
.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:
```bash
cargo run --example weather_adapter
```
---
## 🔐 Integrating User Authentication Potions
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.
### Step 1: Define Your Ports
```rust
// 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<()>;
}
```
### Step 2: Implement Database Adapter
hexser's `Repository`/`QueryRepository` methods are synchronous (there is no built-in async
port trait yet — see [Feature Flags > `async`](#async)), so a bare `.await` inside `save` or
`find_by_username` below would not compile. Bridge an async driver like sqlx with a Tokio
runtime handle instead:
```rust
// Concrete PostgreSQL adapter
struct PostgresUserRepository {
pool: sqlx::PgPool,
runtime: tokio::runtime::Handle,
}
impl Repository<User> for PostgresUserRepository {
fn save(&mut self, user: User) -> HexResult<()> {
// Execute INSERT/UPDATE via sqlx, blocking on the async call from this sync method.
self.runtime.block_on(
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)
)
.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>> {
self.runtime.block_on(
sqlx::query_as!(User, "SELECT * FROM users WHERE username = $1", username)
.fetch_optional(&self.pool)
)
.map_err(|e| Hexserror::adapter(codes::adapter::DB_READ_FAILURE, "Query failed")
.with_source(e))
}
fn find_by_email(&self, email: &str) -> HexResult<Option<User>> {
self.runtime.block_on(
sqlx::query_as!(User, "SELECT * FROM users WHERE email = $1", email)
.fetch_optional(&self.pool)
)
.map_err(|e| Hexserror::adapter(codes::adapter::DB_READ_FAILURE, "Query failed")
.with_source(e))
}
}
```
### Step 3: Implement Session Adapter (Redis or In-Memory)
```rust
// 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()
.map_err(|e| Hexserror::adapter(codes::adapter::CONNECTION_FAILURE, "Redis unavailable")
.with_source(e))?;
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()
.map_err(|e| Hexserror::adapter(codes::adapter::CONNECTION_FAILURE, "Redis unavailable")
.with_source(e))?;
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(())
}
}
```
### Step 4: Wire Adapters to Application
```rust
// 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,
runtime: tokio::runtime::Handle::current(),
}),
session_port: Box::new(RedisSessionAdapter { client: redis_client }),
}
}
}
```
---
## 🔄 Transactional Directives: ProcessOrder Example
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.
### Port Definitions
hexser's own port traits (`Repository`, `QueryRepository`, ...) are synchronous; there is no
built-in async port trait yet (see [Feature Flags > `async`](#async)). These example ports are
your own trait definitions, so they are free to be `async fn` — but note that a native
`async fn` in a trait is not `dyn`-compatible, so a `Box<dyn ProductRepository>` (used below)
requires the `async-trait` crate, which the `async` feature flag adds as a dependency:
```rust
// Ports accepting a transaction context
#[async_trait::async_trait]
trait ProductRepository {
async fn decrement_stock(&self, tx: &mut PgTransaction, product_id: &str, qty: u32) -> HexResult<()>;
}
#[async_trait::async_trait]
trait OrderRepository {
async fn create_order(&self, tx: &mut PgTransaction, order: Order) -> HexResult<()>;
}
trait EventBus {
fn publish(&self, event: OrderCreated) -> HexResult<()>;
}
```
### Directive Handler with Transaction
```rust
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(())
}
}
```
### Adapter Implementation (PostgreSQL)
```rust
struct PostgresProductRepository;
#[async_trait::async_trait]
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:**
- Pass `&mut PgTransaction` (or equivalent) to all repository methods within the transaction.
- Rollback is automatic via Rust's `Drop` trait if any error occurs before `commit()`.
- Publish events only after successful commit to ensure consistency.
---
## 🔗 Composite Adapters: ProfileRepository Example
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.
### Port Definition
Like the transactional example above, this is your own port trait (not one of hexser's), and
since the composite adapter below needs `async fn`, the trait must be `#[async_trait::async_trait]`
for `find_by_id` to be both `async` and usable as it is here:
```rust
#[async_trait::async_trait]
trait ProfileRepository {
async fn find_by_id(&self, user_id: &str) -> HexResult<Profile>;
}
```
### Composite Adapter Implementation
```rust
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))),
}
}
}
#[async_trait::async_trait]
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)
}
}
```
### Handling Data Inconsistencies
- **Primary Source Failure:** Return `Hexserror::Adapter` or `Hexserror::NotFound` with actionable guidance.
- **Secondary Source Failure:** Degrade gracefully by logging a warning and using defaults (e.g., `Preferences::default()`).
- **Caching Strategy:** Use an LRU cache with TTL to reduce load; invalidate on writes.
- **Stale Data:** Implement cache invalidation hooks or TTL-based expiry for eventually-consistent NoSQL data.
### Caching Strategies
1. **Read-Through Cache:** Check cache before querying databases (shown above).
2. **Write-Through Cache:** Invalidate or update cache on writes.
3. **TTL-Based Expiry:** Use a cache with time-to-live for each entry.
```rust
// 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
}
}
```
---