Traits are Rust’s way to describe shared behavior. Generics let code use that behavior without committing to one implementation. Together they can produce systems whose components are replaceable, testable, and still optimized as concrete types.
This article builds a small record-processing pipeline. The goal is not to invent a framework; it is to identify where a trait creates a useful boundary and where an ordinary type would be simpler.
Start with the domain and its failures
Interfaces are easier to design after naming the data and the errors that cross them.
use std::{error::Error, fmt};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Record {
pub id: String,
pub fields: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum PipelineError {
Read(String),
Invalid { id: String, reason: String },
Transform { id: String, reason: String },
Write(String),
}
impl fmt::Display for PipelineError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Read(message) => write!(f, "read failed: {message}"),
Self::Invalid { id, reason } => write!(f, "record {id} is invalid: {reason}"),
Self::Transform { id, reason } => write!(f, "record {id} could not be transformed: {reason}"),
Self::Write(message) => write!(f, "write failed: {message}"),
}
}
}
impl Error for PipelineError {}
The error variants retain the operation and record identifier. A pipeline that flattens everything into Box<dyn Error> at every boundary is flexible, but it makes policy difficult: callers cannot tell whether a record should be rejected, retried, or treated as a system failure.
Define narrow behavioral boundaries
Each trait below has one responsibility and no dependency on a concrete file format or database client.
pub trait Reader {
fn read(&mut self) -> Result<Vec<Record>, PipelineError>;
}
pub trait Validator {
fn validate(&self, record: &Record) -> Result<(), PipelineError>;
}
pub trait Transformer {
fn transform(&self, record: Record) -> Result<Record, PipelineError>;
}
pub trait Writer {
fn write(&mut self, records: &[Record]) -> Result<(), PipelineError>;
}
The receiver choices are deliberate:
- readers and writers use
&mut selfbecause they normally advance cursors, buffers, or transactions; - validators borrow a record because validation should not consume it;
- transformers own a record so they can reuse its existing allocations.
Those signatures communicate lifecycle and cost. A trait is most useful when it states such constraints, not when it merely renames a single function.
Compose implementations with generics
pub struct Pipeline<R, V, T, W> {
reader: R,
validator: V,
transformer: T,
writer: W,
}
impl<R, V, T, W> Pipeline<R, V, T, W>
where
R: Reader,
V: Validator,
T: Transformer,
W: Writer,
{
pub fn new(reader: R, validator: V, transformer: T, writer: W) -> Self {
Self { reader, validator, transformer, writer }
}
pub fn run(&mut self) -> Result<usize, PipelineError> {
let output: Vec<Record> = self
.reader
.read()?
.into_iter()
.map(|record| {
self.validator.validate(&record)?;
self.transformer.transform(record)
})
.collect::<Result<_, _>>()?;
let count = output.len();
self.writer.write(&output)?;
Ok(count)
}
pub fn into_writer(self) -> W {
self.writer
}
}
This is static dispatch. For each concrete combination of R, V, T, and W, the compiler generally monomorphizes the used generic code. That permits inlining and avoids a virtual call, at the cost of potentially larger binaries and longer compile times when many combinations are instantiated.
The pipeline also defines failure behavior: it stops at the first invalid record, writes only after every transformation succeeds, and passes the whole batch to the writer. A production design must decide whether that atomic-batch behavior is desirable. Streaming millions of records through a Vec is not.
Concrete components
pub struct VecReader {
records: Option<Vec<Record>>,
}
impl VecReader {
pub fn new(records: Vec<Record>) -> Self {
Self { records: Some(records) }
}
}
impl Reader for VecReader {
fn read(&mut self) -> Result<Vec<Record>, PipelineError> {
self.records
.take()
.ok_or_else(|| PipelineError::Read("reader can only be consumed once".into()))
}
}
pub struct MinimumFields(pub usize);
impl Validator for MinimumFields {
fn validate(&self, record: &Record) -> Result<(), PipelineError> {
if record.fields.len() < self.0 {
return Err(PipelineError::Invalid {
id: record.id.clone(),
reason: format!("expected at least {} fields", self.0),
});
}
Ok(())
}
}
pub struct Uppercase;
impl Transformer for Uppercase {
fn transform(&self, mut record: Record) -> Result<Record, PipelineError> {
for field in &mut record.fields {
field.make_ascii_uppercase();
}
Ok(record)
}
}
#[derive(Default)]
pub struct VecWriter {
pub records: Vec<Record>,
}
impl Writer for VecWriter {
fn write(&mut self, records: &[Record]) -> Result<(), PipelineError> {
self.records.extend_from_slice(records);
Ok(())
}
}
VecReader models ownership precisely: after yielding its records once, a second read is an error. Uppercase mutates owned strings instead of building a second vector of strings.
Composable validators with an extension trait
An extension trait can add a combinator to every validator without changing the base trait.
pub trait ValidatorExt: Validator + Sized {
fn and<V: Validator>(self, other: V) -> And<Self, V> {
And { first: self, second: other }
}
}
impl<T: Validator> ValidatorExt for T {}
pub struct And<A, B> {
first: A,
second: B,
}
impl<A: Validator, B: Validator> Validator for And<A, B> {
fn validate(&self, record: &Record) -> Result<(), PipelineError> {
self.first.validate(record)?;
self.second.validate(record)
}
}
pub struct NonEmptyId;
impl Validator for NonEmptyId {
fn validate(&self, record: &Record) -> Result<(), PipelineError> {
if record.id.trim().is_empty() {
return Err(PipelineError::Invalid {
id: record.id.clone(),
reason: "id is empty".into(),
});
}
Ok(())
}
}
The composed type is And<NonEmptyId, MinimumFields>. That type can itself be composed because the blanket ValidatorExt implementation applies to every Validator.
A focused test
#[test]
fn pipeline_validates_transforms_and_writes() {
let input = vec![Record {
id: "r-1".into(),
fields: vec!["alpha".into(), "beta".into()],
}];
let validator = NonEmptyId.and(MinimumFields(2));
let mut pipeline = Pipeline::new(
VecReader::new(input),
validator,
Uppercase,
VecWriter::default(),
);
assert_eq!(pipeline.run().unwrap(), 1);
let writer = pipeline.into_writer();
assert_eq!(writer.records[0].fields, ["ALPHA", "BETA"]);
}
The in-memory reader and writer are not special mocks. They are ordinary implementations of the same contract, which makes the test exercise the real orchestration code without files or a database.
Generics or trait objects?
Static composition is not always the right answer. If a configuration file chooses a writer at runtime, the concrete type is not known at compilation. A trait object is appropriate:
fn configured_writer(kind: &str) -> Result<Box<dyn Writer>, PipelineError> {
match kind {
"memory" => Ok(Box::new(VecWriter::default())),
other => Err(PipelineError::Write(format!("unknown writer: {other}"))),
}
}
Box<dyn Writer> stores a pointer to a value and a pointer to a virtual method table. Calls use dynamic dispatch. The performance difference is often irrelevant beside I/O, while the reduction in generic type complexity can be substantial.
Not every trait is usable behind dyn. A dynamically compatible trait cannot require methods whose generic or Self-dependent signatures prevent building one common virtual table. The compiler reports these cases; the API design question is whether those methods belong on the object-safe core trait or on a Sized extension trait.
Associated types when one implementation chooses one type
If a reader should yield items incrementally, an associated type can express the item/error relationship:
trait StreamingReader {
type Item;
type Error;
fn next(&mut self) -> Result<Option<Self::Item>, Self::Error>;
}
Use a generic type parameter when the caller chooses the type; use an associated type when each implementation has one natural choice. This is a design heuristic, not a mechanical rule.
Production concerns the simple example hides
A real pipeline needs more than traits:
- Backpressure: bound buffers so a fast reader cannot outrun a slow sink.
- Streaming: process chunks or iterators rather than collecting the whole input.
- Transactions: define whether a partially written batch can be retried safely.
- Idempotency: retries must not duplicate side effects.
- Concurrency: add
SendandSynconly where threads or async tasks require them. - Observability: record counts, latency, and error classes at component boundaries.
- Error policy: distinguish bad records from transient infrastructure failures.
Traits make those policies expressible; they do not choose them automatically.
Design rules that hold up
- Start with concrete code, then extract a trait at a real substitution boundary.
- Keep traits small and name them after behavior.
- Let ownership in method signatures communicate lifetime and cost.
- Prefer generics for fixed compositions and trait objects for runtime-selected components.
- Preserve structured failures across the boundary.
- Test orchestration with small ordinary implementations.
- Measure before turning every call into abstraction for performance reasons.
Used this way, traits are not “interfaces from another language.” They combine behavior with Rust’s ownership and type system, letting an architecture state both what a component does and how data may move through it.