Hexagonal Architecture
ClawDesk implements the hexagonal architecture (also known as ports-and-adapters) pattern throughout its crate structure. This architecture isolates the domain core from all external I/O, making the system testable, extensible, and vendor-agnostic.
Core Concept
The hexagonal architecture divides the system into three concentric regions:
- Port: A Rust trait that defines what the core needs from the outside world (driven) or what the outside world can ask of the core (driving).
- Adapter: A concrete implementation of a port trait that connects to a real system.
- Core: Pure business logic that depends only on port trait definitions, never on adapter implementations.
Port Classification
ClawDesk defines two categories of ports:
Driving Ports (Primary / Inbound)
Driving ports are interfaces through which external actors invoke the application. In ClawDesk, these are implemented as trait objects or direct function calls on domain services.
| Port | Crate | Trait / Entry Point | Adapters |
|---|---|---|---|
| Message Ingestion | clawdesk-gateway | POST /v1/messages | REST, WebSocket |
| CLI Commands | clawdesk-cli | Clap command dispatch | Terminal |
| IPC Commands | clawdesk-tauri | Tauri IPC handlers | Desktop WebView |
| Admin Operations | clawdesk-gateway | admin::* routes | REST |
| Cron Triggers | clawdesk-cron | CronExecutor::tick() | Internal timer |
Driven Ports (Secondary / Outbound)
Driven ports are interfaces through which the application accesses external systems. These are defined as async traits in dedicated port crates.
| Port | Crate | Trait | Adapters |
|---|---|---|---|
| Session Persistence | clawdesk-storage | SessionStore | SochDB, In-Memory |
| Conversation History | clawdesk-storage | ConversationStore | SochDB |
| Configuration | clawdesk-storage | ConfigStore | SochDB, File |
| Vector Search | clawdesk-storage | VectorStore | SochDB HNSW |
| LLM Inference | clawdesk-providers | Provider | Anthropic, OpenAI, Gemini, Ollama, Bedrock |
| Channel I/O | clawdesk-channel | Channel | Slack, Discord, Telegram, etc. |
| Audit Logging | clawdesk-security | AuditLogger | File, SochDB |
Storage Ports in Detail
The clawdesk-storage crate is the canonical example of the ports pattern. It contains only trait definitions — zero implementations:
// crates/clawdesk-storage/src/lib.rs
/// Port for session persistence.
#[async_trait]
pub trait SessionStore: Send + Sync + 'static {
/// Retrieve a session by its unique key.
async fn get_session(&self, key: &SessionKey) -> Result<Option<Session>, StorageError>;
/// Persist a session, creating or updating as needed.
async fn put_session(&self, session: &Session) -> Result<(), StorageError>;
/// Remove a session and all associated data.
async fn delete_session(&self, key: &SessionKey) -> Result<(), StorageError>;
/// List sessions matching the given filter.
async fn list_sessions(&self, filter: SessionFilter) -> Result<Vec<Session>, StorageError>;
}
/// Port for conversation history storage.
#[async_trait]
pub trait ConversationStore: Send + Sync + 'static {
async fn append_message(
&self,
session: &SessionKey,
message: &NormalizedMessage,
) -> Result<MessageId, StorageError>;
async fn get_history(
&self,
session: &SessionKey,
limit: usize,
) -> Result<Vec<NormalizedMessage>, StorageError>;
async fn search_conversations(
&self,
query: &str,
scope: SearchScope,
) -> Result<Vec<SearchResult>, StorageError>;
}
/// Port for vector similarity search.
#[async_trait]
pub trait VectorStore: Send + Sync + 'static {
async fn upsert_embedding(
&self,
id: &str,
embedding: &[f32],
metadata: serde_json::Value,
) -> Result<(), StorageError>;
async fn search_similar(
&self,
query_embedding: &[f32],
top_k: usize,
filter: Option<VectorFilter>,
) -> Result<Vec<VectorMatch>, StorageError>;
}
/// Port for configuration persistence.
#[async_trait]
pub trait ConfigStore: Send + Sync + 'static {
async fn load_config(&self) -> Result<ClawDeskConfig, StorageError>;
async fn save_config(&self, config: &ValidatedConfig) -> Result<(), StorageError>;
async fn watch_config(&self) -> Result<ConfigWatcher, StorageError>;
}
By placing traits in their own crate, any crate in the workspace can depend on the port definitions without pulling in heavyweight adapter dependencies (SochDB, network libraries, etc.). This is the Dependency Inversion Principle applied at the crate level.
Adapter Implementation: SochDB
The clawdesk-sochdb crate implements all four storage port traits:
// crates/clawdesk-sochdb/src/session_store.rs
use clawdesk_storage::{SessionStore, StorageError};
use clawdesk_types::{Session, SessionKey, SessionFilter};
use sochdb::Database;
pub struct SochSessionStore {
db: Database,
}
impl SochSessionStore {
pub fn new(db: Database) -> Self {
Self { db }
}
}
#[async_trait]
impl SessionStore for SochSessionStore {
async fn get_session(&self, key: &SessionKey) -> Result<Option<Session>, StorageError> {
let txn = self.db.begin_read()?;
let table = txn.open_table("sessions")?;
match table.get(key.as_bytes())? {
Some(bytes) => {
let session: Session = bincode::deserialize(&bytes)
.map_err(|e| StorageError::Deserialization(e.to_string()))?;
Ok(Some(session))
}
None => Ok(None),
}
}
async fn put_session(&self, session: &Session) -> Result<(), StorageError> {
let txn = self.db.begin_write()?;
let mut table = txn.open_table("sessions")?;
let bytes = bincode::serialize(session)
.map_err(|e| StorageError::Serialization(e.to_string()))?;
table.insert(session.key().as_bytes(), &bytes)?;
txn.commit()?;
Ok(())
}
// ... remaining methods
}
Channel Port Hierarchy
The clawdesk-channel crate defines a layered trait hierarchy that demonstrates the Interface Segregation Principle:
// crates/clawdesk-channel/src/lib.rs
/// Layer 0: Base channel capability.
/// Every channel adapter MUST implement this trait.
#[async_trait]
pub trait Channel: Send + Sync + 'static {
fn id(&self) -> ChannelId;
fn meta(&self) -> &ChannelMeta;
async fn start(&mut self) -> Result<(), ChannelError>;
async fn send(&self, message: &OutboundMessage) -> Result<(), ChannelError>;
async fn stop(&mut self) -> Result<(), ChannelError>;
}
/// Layer 1: Optional capabilities.
/// Adapters implement only the traits their platform supports.
#[async_trait]
pub trait Threaded: Channel {
async fn reply_in_thread(
&self,
thread_id: &ThreadId,
message: &OutboundMessage,
) -> Result<(), ChannelError>;
}
#[async_trait]
pub trait Streaming: Channel {
async fn send_stream(
&self,
stream: MessageStream,
) -> Result<StreamHandle, ChannelError>;
async fn update_message(
&self,
message_id: &MessageId,
content: &str,
) -> Result<(), ChannelError>;
}
/// Layer 2: Full-featured channel.
/// Only the richest platforms (e.g., Slack) implement this.
pub trait RichChannel: Threaded + Streaming + Reactions + GroupManagement {}
Provider Port
The clawdesk-providers crate defines the LLM inference port:
// crates/clawdesk-providers/src/lib.rs
#[async_trait]
pub trait Provider: Send + Sync + 'static {
/// Human-readable provider name.
fn name(&self) -> &str;
/// Send a chat completion request.
async fn chat(
&self,
request: &ProviderRequest,
) -> Result<ProviderResponse, ProviderError>;
/// Stream a chat completion response.
async fn chat_stream(
&self,
request: &ProviderRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, ProviderError>> + Send>>, ProviderError>;
/// List available models.
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError>;
/// Check provider health / connectivity.
async fn health_check(&self) -> Result<HealthStatus, ProviderError>;
}
Dependency Inversion in Practice
The key insight is how dependencies flow:
Both high-level modules (agents, domain) and low-level modules (SochDB, Slack) depend on abstractions. Abstractions do not depend on details. This is the Dependency Inversion Principle — the "D" in SOLID.
Wiring: Dependency Injection at Bootstrap
All adapters are wired together at application startup in the gateway bootstrap:
// crates/clawdesk-gateway/src/bootstrap.rs
pub async fn bootstrap(config: ValidatedConfig) -> Result<AppState, ClawDeskError> {
// 1. Create the storage adapter (driven port implementation)
let db = sochdb::Database::open(&config.storage.path)?;
let session_store: Arc<dyn SessionStore> = Arc::new(
SochSessionStore::new(db.clone())
);
let conversation_store: Arc<dyn ConversationStore> = Arc::new(
SochConversationStore::new(db.clone())
);
let vector_store: Arc<dyn VectorStore> = Arc::new(
SochVectorStore::new(db.clone())
);
// 2. Create provider adapters (driven port implementations)
let providers = ProviderRegistry::from_config(&config.providers)?;
// 3. Create channel adapters (driven port implementations)
let channels = ChannelRegistry::from_config(&config.channels).await?;
// 4. Wire the agent pipeline with all dependencies
let pipeline = PipelineBuilder::new()
.session_store(session_store.clone())
.conversation_store(conversation_store.clone())
.vector_store(vector_store.clone())
.provider_registry(providers)
.security(SecurityLayer::new(&config.security)?)
.build()?;
// 5. Construct application state
Ok(AppState {
config: ArcSwap::new(Arc::new(config)),
pipeline,
channels,
session_store,
conversation_store,
vector_store,
})
}
Testing Benefits
The hexagonal architecture enables three levels of testing without external dependencies:
Unit Tests with Mock Ports
#[tokio::test]
async fn test_pipeline_with_mock_stores() {
// Create mock implementations of all ports
let session_store = Arc::new(InMemorySessionStore::new());
let conv_store = Arc::new(InMemoryConversationStore::new());
let vector_store = Arc::new(InMemoryVectorStore::new());
let provider = Arc::new(MockProvider::with_response("Hello!"));
let pipeline = PipelineBuilder::new()
.session_store(session_store)
.conversation_store(conv_store)
.vector_store(vector_store)
.provider(provider)
.build()
.unwrap();
let msg = NormalizedMessage::text("Hi there");
let response = pipeline.process(msg).await.unwrap();
assert_eq!(response.content(), "Hello!");
}
Integration Tests with Real Storage
#[tokio::test]
async fn test_session_round_trip_sochdb() {
let db = sochdb::Database::open_temp().unwrap();
let store = SochSessionStore::new(db);
let session = Session::new(SessionKey::generate());
store.put_session(&session).await.unwrap();
let loaded = store.get_session(session.key()).await.unwrap();
assert_eq!(loaded, Some(session));
}
Contract Tests for Port Compliance
/// Generic test suite that any SessionStore implementation must pass.
pub async fn session_store_contract_tests<S: SessionStore>(store: S) {
// Test 1: Get non-existent session returns None
let key = SessionKey::generate();
assert!(store.get_session(&key).await.unwrap().is_none());
// Test 2: Put then get returns same session
let session = Session::new(key.clone());
store.put_session(&session).await.unwrap();
let loaded = store.get_session(&key).await.unwrap();
assert_eq!(loaded.unwrap(), session);
// Test 3: Delete removes session
store.delete_session(&key).await.unwrap();
assert!(store.get_session(&key).await.unwrap().is_none());
// Test 4: List with filter
for i in 0..5 {
let s = Session::with_channel(SessionKey::generate(), ChannelId::new(format!("ch-{i}")));
store.put_session(&s).await.unwrap();
}
let results = store.list_sessions(SessionFilter::by_channel("ch-2")).await.unwrap();
assert_eq!(results.len(), 1);
}
Comparison: Why Not Layered?
| Aspect | Layered Architecture | Hexagonal (ClawDesk) |
|---|---|---|
| Dependency direction | Top → Bottom (fixed) | All arrows point inward to abstractions |
| Testing | Requires mocking entire layers | Mock individual ports |
| Adding a new DB | Touch every layer | Implement port traits only |
| Adding a new channel | Restructure multiple layers | Implement Channel trait |
| Compile-time checking | Limited | Trait bounds enforce contracts |
| Circular dependencies | Easy to introduce | Structurally prevented |
Do not let adapter crates depend on each other. For example, clawdesk-sochdb must never import from clawdesk-channels. All coordination happens through the domain core using port traits. This invariant is enforced by the crate dependency graph.
Summary
The hexagonal architecture in ClawDesk provides:
- Testability — Every boundary is a trait that can be mocked
- Extensibility — New adapters require implementing a trait, not modifying existing code
- Compile-time safety — The Rust type system enforces port contracts
- Parallel compilation — Port crates have minimal dependencies, enabling wide parallelism
- Vendor independence — Swap LLM providers, databases, or channels without touching business logic