Custom Adapters
This guide walks through building a production-quality custom adapter from scratch.
The worked example implements a hypothetical PlaywrightService — a browser automation
adapter that drives the Playwright protocol instead of CDP.
By the end you will know how to:
- Implement any port trait as a new adapter
- Register the adapter in the service registry
- Wrap the adapter in resilience primitives
- Write integration tests against it
Prerequisites
Read Architecture first. Adapters live in src/adapters/ and implement
traits defined in src/ports.rs. The domain never imports adapters — only ports.
Step 1: Choose the right port
| Port | Use when |
|---|---|
ScrapingService | Fetching or processing content in a new way |
AIProvider | Adding a new LLM or language model API |
CachePort | Adding a new cache backend (Redis, Memcached, …) |
SigningPort | Attaching signatures, HMAC tokens, or authentication material to outgoing requests |
PlaywrightService fetches rendered HTML, so it implements ScrapingService.
Step 2: Scaffold the adapter
Create src/adapters/playwright.rs:
//! Playwright browser adapter.
use std::time::Duration;
use serde_json::Value;
use crate::domain::error::{StygianError, ServiceError};
use crate::ports::{ScrapingService, ServiceInput, ServiceOutput};
// ── Configuration ─────────────────────────────────────────────────────────────
/// Configuration for the Playwright adapter.
///
/// # Example
///
/// ```rust
/// use stygian_graph::adapters::playwright::PlaywrightConfig;
///
/// let cfg = PlaywrightConfig {
/// ws_endpoint: "ws://localhost:3000/playwright".into(),
/// default_timeout: std::time::Duration::from_secs(30),
/// headless: true,
/// };
/// ```
#[derive(Debug, Clone)]
pub struct PlaywrightConfig {
pub ws_endpoint: String,
pub default_timeout: Duration,
pub headless: bool,
}
impl Default for PlaywrightConfig {
fn default() -> Self {
Self {
ws_endpoint: "ws://localhost:3000/playwright".into(),
default_timeout: Duration::from_secs(30),
headless: true,
}
}
}
// ── Adapter ───────────────────────────────────────────────────────────────────
/// Browser adapter backed by a Playwright JSON-RPC server.
pub struct PlaywrightService {
config: PlaywrightConfig,
}
impl PlaywrightService {
pub fn new(config: PlaywrightConfig) -> Self {
Self { config }
}
}
// ── Port implementation ───────────────────────────────────────────────────────
impl ScrapingService for PlaywrightService {
fn name(&self) -> &'static str {
"playwright"
}
/// Navigate to `input.url`, wait for network idle, return rendered HTML.
///
/// # Errors
///
/// `ServiceError::Unavailable` — Playwright server is unreachable.
/// `ServiceError::Timeout` — navigation exceeded `default_timeout`.
async fn execute(&self, input: ServiceInput) -> crate::domain::error::Result<ServiceOutput> {
// Real implementation would:
// 1. Connect to self.config.ws_endpoint via WebSocket
// 2. Send Browser.newPage
// 3. Navigate to input.url with self.config.default_timeout
// 4. Await "networkidle" lifecycle event
// 5. Call Page.getContent() for rendered HTML
// 6. Close the page
Err(StygianError::Service(ServiceError::Unavailable(
format!("PlaywrightService not connected (url={})", input.url),
)))
}
}
Step 3: Re-export
Add a pub mod playwright; line to src/adapters/mod.rs:
// src/adapters/mod.rs
pub mod http;
pub mod browser;
pub mod claude;
// ... existing adapters ...
pub mod playwright; // ← add this line
Step 4: Register in the service registry
In your binary entry point or application/executor.rs startup code:
use std::sync::Arc;
use stygian_graph::adapters::playwright::{PlaywrightConfig, PlaywrightService};
use stygian_graph::application::registry::ServiceRegistry;
let registry = ServiceRegistry::new();
let config = PlaywrightConfig {
ws_endpoint: std::env::var("PLAYWRIGHT_WS_ENDPOINT")
.unwrap_or_else(|_| "ws://localhost:3000/playwright".into()),
..Default::default()
};
registry.register(
"playwright".into(),
Arc::new(PlaywrightService::new(config)),
);
Pipelines can now reference the adapter with service = "playwright" in any node.
Step 5: Add resilience wrappers
Wrap before registering to get circuit-breaker and retry behaviour for free:
use std::sync::Arc;
use std::time::Duration;
use stygian_graph::adapters::resilience::{CircuitBreakerImpl, RetryPolicy, retry};
let cb = CircuitBreakerImpl::new(
5, // open after 5 consecutive failures
Duration::from_secs(120), // half-open probe after 2 min
);
let policy = RetryPolicy::new(
3, // max 3 attempts
Duration::from_millis(200), // initial back-off
Duration::from_secs(5), // cap
);
// Wrap your adapter calls with circuit breaker + retry:
// retry(&policy, || async { playwright_service.call(input.clone()).await }).await?
Step 6: Integration tests
Use stygian's built-in mock transport for unit tests that don't require a real server:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use stygian_graph::ports::ServiceInput; fn make_input(url: &str) -> ServiceInput { ServiceInput { url: url.into(), config: serde_json::Value::Null, ..Default::default() } } #[tokio::test] async fn returns_unavailable_when_not_connected() { let svc = PlaywrightService::new(PlaywrightConfig::default()); let err = svc.execute(make_input("https://example.com")).await.unwrap_err(); assert!(err.to_string().contains("not connected")); } } }
For full integration tests that require a real Playwright server, mark them with
#[ignore = "requires playwright server"] so CI passes without the dependency.
Adapter checklist
Before merging a new adapter:
- Implements the correct port trait
-
name()returns a lowercase, kebab-case identifier - All public types have doc comments with an example
-
Defaultimpl provided where sensible - Config can be loaded from environment variables
- At least one unit test that does not require external services
-
Integration tests marked
#[ignore = "requires …"] -
Re-exported from
src/adapters/mod.rs - Registered in the service registry example in the binary