cruxi 0.2.0

Minimal, transport-agnostic hexagonal architecture framework
Documentation

cruxi

A transport-agnostic core framework for building hexagonal Rust services.

Purpose

cruxi defines the core ports and building blocks for a 4-layer architecture:

  • Handler: inbound adapter boundary
  • Service: application orchestration
  • Repository: domain persistence boundary
  • Provider: infrastructure boundary
  • Validator: reusable validation abstraction
  • Context: request-scoped metadata, cancellation, deadlines
┌──────────────────────────────────────┐
│ INBOUND TRANSPORTS                   │
│ (HTTP, gRPC, MQTT, TCP)              │
└──────────────────────────────────────┘
              ↓
┌──────────────────────────────────────┐
│ HANDLER (Inbound Adapter)            │
│ • Receives requests                  │
│ • Validates transport format         │
│ • Delegates to Service               │
└──────────────────────────────────────┘
              ↓
┌──────────────────────────────────────┐
│ SERVICE (Application Layer)          │
│ • Business logic orchestration       │
│ • Authorization checks               │
│ • Coordinates Repositories           │
└──────────────────────────────────────┘
              ↓
┌──────────────────────────────────────┐
│ REPOSITORY (Domain Layer)            │
│ • Domain validation                  │
│ • Transactional integrity            │
│ • Calls Providers                    │
└──────────────────────────────────────┘
              ↓
┌──────────────────────────────────────┐
│ PROVIDER (Infrastructure)            │
│ • Database I/O                       │
│ • HTTP API calls                     │
│ • Message queues                     │
└──────────────────────────────────────┘

Design Principles

Zero external dependencies in the core (only std + thiserror)
Generic type safety via trait generics on Req/Resp
Pattern matching for all control flow and error handling
No .unwrap() - explicit error handling throughout

Features

async - Enables async trait variants (requires async-trait)

Usage

Use function adapters (HandlerFn, ServiceFn, RepositoryFn, ProviderFn, ValidatorFn) for quick composition, or implement traits directly for richer behavior.

Enable the async feature to use async trait variants (AsyncHandler, AsyncService, etc.).

Sync/Async Method Disambiguation

When both sync and async traits are in scope under all-features, use Universal Function Call Syntax (UFCS) style calls to disambiguate method resolution:

use cruxi::{AsyncHandler, Context, Handler, HandlerFn};

let handler = HandlerFn::new(|_ctx: &Context, req: i32| -> Result<i32, &'static str> {
    Ok(req * 2)
});
let ctx = Context::new();

// Sync call
let sync_result = Handler::handle(&handler, &ctx, 21);
assert_eq!(sync_result.ok(), Some(42));

// Async call
let async_future = AsyncHandler::handle(&handler, &ctx, 21);
drop(async_future);

This avoids multiple applicable items in scope errors and makes call intent explicit.

Context done reasons (short example)

Use done_reason() to make cancellation/timeout handling explicit:

use cruxi::{Context, DoneReason};
use std::time::Duration;

let mut cancelled = Context::new();
cancelled.cancel_with_reason(DoneReason::Shutdown);
assert_eq!(cancelled.done_reason(), Some(DoneReason::Shutdown));

let expired = Context::with_timeout(Duration::from_nanos(1));
std::thread::sleep(Duration::from_millis(1));
assert_eq!(expired.done_reason(), Some(DoneReason::DeadlineExceeded));

Use is_cancelled() when you only want explicit cancellation/shutdown, not deadline expiry:

use cruxi::{Context, DoneReason};
use std::time::Duration;

let mut cancelled = Context::new();
cancelled.cancel_with_reason(DoneReason::Shutdown);
assert!(cancelled.is_cancelled());

let expired = Context::with_timeout(Duration::from_nanos(1));
std::thread::sleep(Duration::from_millis(1));
assert!(!expired.is_cancelled());

Use cancellation_handle() when cancellation must be triggered from a shared owner:

use cruxi::{Context, DoneReason};

let ctx = Context::new();
let handle = ctx.cancellation_handle();
handle.cancel_with_reason(DoneReason::Shutdown);

assert_eq!(ctx.done_reason(), Some(DoneReason::Shutdown));
assert!(handle.is_done());
assert!(handle.is_cancelled());
assert!(!handle.deadline_exceeded());

Deadline propagation (short example)

Use child contexts to propagate the earliest deadline across layers:

use cruxi::Context;
use std::time::Duration;

let parent = Context::with_timeout(Duration::from_secs(30));
let child = parent.child_with_timeout(Duration::from_secs(5));

assert!(parent.deadline().is_some());
assert!(child.deadline().is_some());
assert!(child.time_remaining() <= parent.time_remaining());

Typed metadata accessors (short example)

Prefer typed metadata helpers for request context consistency:

use cruxi::ContextBuilder;

let ctx = ContextBuilder::new()
    .with_request_id("req-900")
    .with_trace_id("trace-900")
    .with_principal("user-42")
    .with_tenant("tenant-7")
    .with_scopes(["tasks:read", "tasks:write"])
    .build();

assert_eq!(ctx.request_id(), Some("req-900"));
assert_eq!(ctx.trace_id(), Some("trace-900"));
assert_eq!(ctx.principal(), Some("user-42"));
assert_eq!(ctx.tenant(), Some("tenant-7"));
assert_eq!(ctx.request_id_value().map(|v| v.as_str()), Some("req-900"));
assert!(ctx.has_scope("tasks:read"));
assert!(!ctx.has_scope("tasks:delete"));
let scopes = ctx
    .scopes_value()
    .map(|value| value.iter().collect::<Vec<_>>())
    .unwrap_or_default();
assert_eq!(scopes, vec!["tasks:read", "tasks:write"]);

Typed wrappers also work directly: with_request_id(RequestId::new(...)), with_tenant(Tenant::new(...)), with_scopes(Scopes::from_values([...])).

For scope processing, Scopes also exposes: iter() for borrowed traversal and into_vec() when ownership is needed. You can also construct it via Scopes::from(vec![...]) or Scopes::from([\"scope:a\", \"scope:b\"]). Identity wrappers expose into_inner() when owned String extraction is needed.

Principal and Tenant are intended for authorization and data-isolation checks in service/repository layers. See runnable example: cargo run --example context_identity_scoping.

Error mapping contract (short example)

Map transport-agnostic error classes to adapter-specific decisions:

use cruxi::{CodedError, ErrorClass, ErrorClassMapper, ErrorMappingContext, map_coded_error};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Decision {
    Client,
    Server,
}

struct Mapper;

impl ErrorClassMapper for Mapper {
    type Decision = Decision;

    fn map(&self, context: ErrorMappingContext) -> Self::Decision {
        match context.class {
            ErrorClass::Validation
            | ErrorClass::Authentication
            | ErrorClass::Authorization
            | ErrorClass::NotFound
            | ErrorClass::Conflict
            | ErrorClass::RateLimited => Decision::Client,
            ErrorClass::Timeout | ErrorClass::Unavailable | ErrorClass::Internal | ErrorClass::Unknown => Decision::Server,
        }
    }
}

let err = CodedError::new("TASK-UPDATE-000404").with_class(ErrorClass::NotFound);
assert_eq!(map_coded_error(&err, &Mapper), Decision::Client);

Example

use cruxi::{Context, Handler, HandlerFn};

#[derive(Clone)]
struct GetUser {
    id: u64,
}

#[derive(Debug)]
struct UserLookupError;

impl std::fmt::Display for UserLookupError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "user lookup failed")
    }
}

impl std::error::Error for UserLookupError {}

let handler: HandlerFn<_, String, UserLookupError> = HandlerFn::new(|_ctx: &Context, req: GetUser| {
    Ok(format!("user:{}", req.id))
});

let result = handler.handle(&Context::new(), GetUser { id: 42 });
assert_eq!(result.ok().as_deref(), Some("user:42"));