Skip to main content

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:

Terminology
  • 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.

PortCrateTrait / Entry PointAdapters
Message Ingestionclawdesk-gatewayPOST /v1/messagesREST, WebSocket
CLI Commandsclawdesk-cliClap command dispatchTerminal
IPC Commandsclawdesk-tauriTauri IPC handlersDesktop WebView
Admin Operationsclawdesk-gatewayadmin::* routesREST
Cron Triggersclawdesk-cronCronExecutor::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.

PortCrateTraitAdapters
Session Persistenceclawdesk-storageSessionStoreSochDB, In-Memory
Conversation Historyclawdesk-storageConversationStoreSochDB
Configurationclawdesk-storageConfigStoreSochDB, File
Vector Searchclawdesk-storageVectorStoreSochDB HNSW
LLM Inferenceclawdesk-providersProviderAnthropic, OpenAI, Gemini, Ollama, Bedrock
Channel I/Oclawdesk-channelChannelSlack, Discord, Telegram, etc.
Audit Loggingclawdesk-securityAuditLoggerFile, 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>;
}
Design Rationale

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:

Dependency Rule

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?

AspectLayered ArchitectureHexagonal (ClawDesk)
Dependency directionTop → Bottom (fixed)All arrows point inward to abstractions
TestingRequires mocking entire layersMock individual ports
Adding a new DBTouch every layerImplement port traits only
Adding a new channelRestructure multiple layersImplement Channel trait
Compile-time checkingLimitedTrait bounds enforce contracts
Circular dependenciesEasy to introduceStructurally prevented
Common Pitfall

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:

  1. Testability — Every boundary is a trait that can be mocked
  2. Extensibility — New adapters require implementing a trait, not modifying existing code
  3. Compile-time safety — The Rust type system enforces port contracts
  4. Parallel compilation — Port crates have minimal dependencies, enabling wide parallelism
  5. Vendor independence — Swap LLM providers, databases, or channels without touching business logic