In distributed systems, the coupling between transport-layer concerns and business logic is a frequent source of technical debt. When an ingester needs to support multiple protocols—such as gRPC, HTTP/1.1, and raw TCP streams—the naive approach often involves leaking transport-specific types into the core domain. By adopting a hexagonal architecture (or ports and adapters pattern) in Rust, we can maintain a clean separation of concerns, ensuring that the domain remains agnostic of the transport mechanism.
This article explores the implementation of a single, unified ingestion core in Rust that serves as the ‘domain’ center, while three distinct client crates serve as the ‘adapters’ that normalize incoming data into a common message format.
Defining the Domain Core
The core of our system is a library crate that defines the domain model and the trait-based ports for ingestion. The crucial step here is to define an ‘IngestionPort’ trait that abstracts the delivery of messages. The core does not know about sockets, request headers, or serialization formats; it only cares about receiving a validated domain object.
pub trait MessageIngestor: Send + Sync {
fn ingest(&self, msg: DomainMessage) -> Result<(), IngestionError>;
}
pub struct IngestionService<T: MessageIngestor> {
handler: T,
}
impl<T: MessageIngestor> IngestionService<T> {
pub fn process(&self, raw: Vec<u8>) -> Result<(), IngestionError> {
let msg = DomainMessage::try_from(raw)?;
self.handler.ingest(msg)
}
}
Implementing the Adapters
With the core defined, we can build independent client crates. Each crate is responsible for the unique idiosyncrasies of its protocol. For instance, the HTTP adapter manages connection pooling and header parsing, while the gRPC adapter handles the protobuf-to-domain mapping. Because these crates depend only on the core library, they remain lightweight and easily swappable.
The gRPC Adapter
The gRPC adapter acts as a shim that translates incoming protobuf messages into our domain-defined structures. By isolating this logic, we ensure that if our protobuf schema evolves, the blast radius is confined to this specific crate.
pub struct GrpcAdapter { service: IngestionService };
#[tonic::async_trait]
impl proto::Ingestor for GrpcAdapter {
async fn send(&self, req: Request<proto::Message>) -> Result<Response<()>, Status> {
let domain_msg = req.into_inner().into();
self.service.process(domain_msg).map_err(|_| Status::internal("failed"))?;
Ok(Response::new(()))
}
}
Trade-offs and Considerations
While this architecture provides excellent decoupling, it introduces a degree of ceremony. Developers must maintain mapping layers between external protocol types and internal domain types. This ‘mapping tax’ is the price paid for long-term maintainability.
- Increased boilerplate due to explicit type conversion.
- Easier unit testing of business logic without mocking network stacks.
- Ability to swap transport layers without altering the domain core.
- Potential performance overhead from extra allocations during mapping.
Conclusion
Hexagonal architecture is a powerful tool for Rust systems that must evolve alongside changing infrastructure requirements. By strictly defining the boundaries between the transport adapters and the domain core, we create a robust system where the core logic remains protected from the volatility of external APIs. While it requires more upfront design, the payoff in testability and modularity is significant for any non-trivial ingestion pipeline.