Design Patterns
Reference for the key Rust design patterns used consistently across the Paladin codebase.
1. Node<T> Entity Pattern
All persistent domain entities are wrapped in Node<T>, which adds identity and
timestamps without polluting the domain data struct:
pub struct Node<T> {
pub id: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub node: T,
}
// Usage — domain type alias
pub type Paladin = Node<PaladinData>;
// Access payload fields through .node
let name = &paladin.node.name;
let model = &paladin.node.model;
2. Builder Pattern (PaladinBuilder)
Complex objects are constructed using a fluent builder that validates configuration before returning the entity.
// crates/paladin-ai-core (application services layer within the crate)
use paladin_ai_core::application::services::paladin::paladin_builder::PaladinBuilder;
use paladin_ports::output::llm_port::LlmPort;
use std::sync::Arc;
let llm: Arc<dyn LlmPort> = Arc::new(my_adapter);
let paladin = PaladinBuilder::new(llm.clone())
.system_prompt("You are a concise assistant.")
.name("MyAgent")
.model("gpt-4")
.temperature(0.7) // 0.0–2.0
.max_loops(3) // default: 1
.stop_word("DONE") // optional stop trigger
.build()
.await?;
build() validates all required fields and returns Err(PaladinError) if any
invariant is violated, so the caller never receives an invalid Paladin.
Builder Conventions
- Required fields are set in
new()(e.g.,llm_port) - Optional fields use
fn field(mut self, …) -> Self(consuming builder) build()orbuild_async()calls the internalvalidate()method- Always return
Result<T, Error>frombuild()— never panic
3. Port Trait Pattern
All external integrations are expressed as async_trait port traits:
use async_trait::async_trait;
#[async_trait]
pub trait LlmPort: Send + Sync {
async fn generate(
&self,
messages: &[Message],
config: &LlmConfig,
) -> Result<LlmResponse, LlmError>;
}
Rules for port traits:
- Must be
Send + Sync— adapters are shared across async tasks viaArc - Return
Result<T, SpecificError>— neveranyhow::Errorin port signatures - Use
#[async_trait]for allasync fnin traits (until RPITIT stabilises) - Define in
crates/paladin-ports/src/output/orsrc/input/
4. Error Handling with thiserror
Each domain module has its own error enum:
#[derive(Debug, thiserror::Error)]
pub enum PaladinError {
#[error("Configuration error: {0}")]
ConfigurationError(String),
#[error("Execution error: {0}")]
ExecutionError(String),
#[error("LLM error: {0}")]
LlmError(#[from] LlmError),
#[error("Timeout after {0} seconds")]
Timeout(u64),
#[error("Stop word detected: {0}")]
StopWordDetected(String),
}
#[derive(Debug, thiserror::Error)]
pub enum BattalionError {
#[error("Paladin error: {0}")]
PaladinError(#[from] PaladinError),
#[error("Formation error: {0}")]
FormationError(String),
#[error("Invalid graph: {0}")]
InvalidGraph(String),
}
Conventions:
- Layer-specific error types (core, ports, infrastructure)
- Use
#[from]for automaticFromimpl when converting inner errors - Never use
.unwrap()or.expect()in production paths - Use
?to propagate errors with context
5. Dependency Injection via Arc<dyn Trait>
Services receive dependencies at construction time via Arc<dyn Port>:
pub struct PaladinExecutionService {
llm: Arc<dyn LlmPort>,
garrison: Option<Arc<dyn GarrisonPort>>,
circuit_breaker: Arc<CircuitBreaker>,
}
impl PaladinExecutionService {
pub fn new(
llm: Arc<dyn LlmPort>,
circuit_breaker: Arc<CircuitBreaker>,
garrison: Option<Arc<dyn GarrisonPort>>,
herald: Option<Arc<dyn Herald>>,
) -> Self { /* … */ }
}
This makes swapping implementations (e.g., MockLlmAdapter in tests,
OpenAIAdapter in production) a construction-time concern only.
6. Feature-Gated Modules
Optional infrastructure dependencies are gated behind Cargo feature flags to keep compilation lean:
// crates/paladin-memory/src/lib.rs
/// SQLite-backed garrison (requires feature `sqlite`)
#[cfg(feature = "sqlite")]
pub mod sqlite_garrison;
/// Qdrant-backed sanctum (requires feature `qdrant`)
#[cfg(feature = "qdrant")]
pub mod qdrant_sanctum_adapter;
And in Cargo.toml:
[features]
sqlite = ["sqlx/sqlite"]
qdrant = ["qdrant-client"]
7. Service Composition
Application services compose port dependencies to implement use cases:
pub struct FormationService {
paladins: Vec<Paladin>,
llm: Arc<dyn LlmPort>,
garrison: Option<Arc<dyn GarrisonPort>>,
}
impl FormationService {
/// Execute the formation: each Paladin's output feeds the next
pub async fn execute(&self, input: &str) -> Result<FormationResult, BattalionError> {
let mut current_input = input.to_string();
let mut results = Vec::new();
for paladin in &self.paladins {
let result = self.execute_paladin(paladin, ¤t_input).await?;
current_input = result.output.clone();
results.push(result);
}
Ok(FormationResult { results })
}
}
8. Circuit Breaker
PaladinExecutionService accepts a CircuitBreaker to prevent cascading
failures when an LLM provider is unavailable:
use paladin_ai_core::infrastructure::resilience::circuit_breaker::CircuitBreaker;
use std::time::Duration;
let circuit_breaker = Arc::new(CircuitBreaker::new(
3, // open after 3 consecutive failures
2, // close after 2 consecutive successes
Duration::from_secs(30), // half-open probe interval
));
9. Testing Conventions
Unit tests — in the same file
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_paladin_data_validates_empty_prompt() {
let data = PaladinData { system_prompt: "".into(), /* … */ };
assert!(data.validate().is_err());
}
}
Integration tests — in tests/
// tests/formation_integration_test.rs
use paladin_ai_core::…;
use paladin_llm::mock::MockLlmAdapter;
#[tokio::test]
async fn test_formation_passes_output_to_next() {
let mock = Arc::new(MockLlmAdapter::new().with_response("step result".into()));
// …
}
Doc tests — in rustdoc comments
/// Validates the Paladin configuration.
///
/// ```rust
/// # use paladin_ai_core::platform::container::paladin::PaladinData;
/// let data = PaladinData { system_prompt: "Hello".into(), … };
/// assert!(data.validate().is_ok());
/// ```
pub fn validate(&self) -> Result<(), PaladinError> { /* … */ }