Rust traits are often introduced as “interfaces,” but that comparison leaves out the most useful part. A trait is a contract that can participate in static dispatch, dynamic dispatch, associated types, blanket implementations, and compile-time composition. Used carefully, traits make dependencies explicit without turning every function into a generic abstraction.

This article develops a small processing pipeline whose components can be replaced independently. The example is deliberately complete: it compiles on stable Rust, reports meaningful errors, and tests behavior rather than merely checking that a call returned Ok.

Begin with the boundary, not the implementation

Assume that records arrive from a source, pass through validation, and are written to a sink. The three responsibilities change for different reasons:

  • a source may move from an in-memory fixture to a database or message queue;
  • validation rules evolve with the domain;
  • a sink may write to a file, an API, or another queue.

Those are useful seams. Logging every method or wrapping a single concrete type in a one-implementation trait is usually not.

The source owns the choice of item type through an associated type:

use std::{error::Error, fmt};
      
      #[derive(Clone, Debug, PartialEq, Eq)]
      struct Record {
          id: u64,
          email: String,
          active: bool,
      }
      
      #[derive(Debug, PartialEq, Eq)]
      enum PipelineError {
          Read(String),
          Validation { record_id: u64, reason: String },
          Write(String),
      }
      
      impl fmt::Display for PipelineError {
          fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
              match self {
                  Self::Read(reason) => write!(f, "source failed: {reason}"),
                  Self::Validation { record_id, reason } => {
                      write!(f, "record {record_id} is invalid: {reason}")
                  }
                  Self::Write(reason) => write!(f, "sink failed: {reason}"),
              }
          }
      }
      
      impl Error for PipelineError {}
      
      trait Source {
          type Item;
      
          fn read(&mut self) -> Result<Option<Self::Item>, PipelineError>;
      }
      
      trait Validator<T> {
          fn validate(&self, value: &T) -> Result<(), PipelineError>;
      }
      
      trait Sink<T> {
          fn write(&mut self, value: T) -> Result<(), PipelineError>;
      }
      

Source::Item says that each source has one natural output type. This is clearer than making every call generic over an unrelated T. The Option distinguishes end-of-input from failure, while Result preserves the failure.

Compose rules without hiding control flow

A reusable validator should do one thing and return the same error type as the pipeline. Small validators can then be combined:

struct HasEmail;
      
      impl Validator<Record> for HasEmail {
          fn validate(&self, record: &Record) -> Result<(), PipelineError> {
              if record.email.contains('@') {
                  Ok(())
              } else {
                  Err(PipelineError::Validation {
                      record_id: record.id,
                      reason: "email must contain @".into(),
                  })
              }
          }
      }
      
      struct IsActive;
      
      impl Validator<Record> for IsActive {
          fn validate(&self, record: &Record) -> Result<(), PipelineError> {
              if record.active {
                  Ok(())
              } else {
                  Err(PipelineError::Validation {
                      record_id: record.id,
                      reason: "record must be active".into(),
                  })
              }
          }
      }
      
      struct And<A, B>(A, B);
      
      impl<T, A, B> Validator<T> for And<A, B>
      where
          A: Validator<T>,
          B: Validator<T>,
      {
          fn validate(&self, value: &T) -> Result<(), PipelineError> {
              self.0.validate(value)?;
              self.1.validate(value)
          }
      }
      

The composition is explicit: the second validator runs only if the first succeeds. That behavior is visible in ordinary Rust control flow rather than hidden in a framework.

Static dispatch when the graph is known

Generics are a good fit when the component graph is known at compile time.

fn run<S, V, O>(
          source: &mut S,
          validator: &V,
          sink: &mut O,
      ) -> Result<(), PipelineError>
      where
          S: Source<Item = Record>,
          V: Validator<Record>,
          O: Sink<Record>,
      {
          while let Some(record) = source.read()? {
              validator.validate(&record)?;
              sink.write(record)?;
          }
          Ok(())
      }
      

The compiler knows the concrete source, validator, and sink types and can monomorphize the function. The trait bounds also act as documentation: run needs exactly a source of Record, a validator for Record, and a sink that consumes Record.

Static dispatch is attractive for hot paths and library code, but it is not always the best architectural choice. Large generic component graphs can make types, build times, and compiler diagnostics harder to manage.

Dynamic dispatch when composition is runtime data

Sometimes the set of components is chosen from configuration, plugins, or command-line options. Then a trait object can be a clearer contract:

fn validate_all(
          validators: &[Box<dyn Validator<Record>>],
          record: &Record,
      ) -> Result<(), PipelineError> {
          for validator in validators {
              validator.validate(record)?;
          }
          Ok(())
      }
      

dyn Trait trades compile-time knowledge for runtime flexibility. The indirect call is real, but in most I/O-heavy systems it is rarely the dominant cost. Measure before contorting an architecture to remove it.

Trait objects also impose object-safety rules. A runtime plugin interface should generally be small, explicit, and designed intentionally for dynamic dispatch rather than converted mechanically from a generic API.

Associated types versus generic parameters

A useful rule of thumb is:

  • use an associated type when an implementation has one natural type choice;
  • use a generic parameter when the caller should be able to choose the type relationship repeatedly.

A Source typically has one natural item type, which makes type Item useful. Validator<T> is generic because the abstraction is naturally “a validator of T” and a type may participate in more than one such relationship.

The decision is not about syntax preference. It communicates who owns the type choice.

Use concrete adapters at infrastructure boundaries

Traits become especially useful around effects:

#[derive(Default)]
      struct VecSource {
          records: std::collections::VecDeque<Record>,
      }
      
      impl Source for VecSource {
          type Item = Record;
      
          fn read(&mut self) -> Result<Option<Record>, PipelineError> {
              Ok(self.records.pop_front())
          }
      }
      
      #[derive(Default)]
      struct VecSink(Vec<Record>);
      
      impl Sink<Record> for VecSink {
          fn write(&mut self, record: Record) -> Result<(), PipelineError> {
              self.0.push(record);
              Ok(())
          }
      }
      

A production source could wrap SQLx, Kafka, a filesystem reader, or an HTTP client. The domain pipeline does not need those dependency types in its signature. That separation improves testing and keeps infrastructure changes from leaking through the whole application.

Keep domain errors at the boundary

A modular system becomes hard to use when every component leaks a different dependency-specific error. A storage client may return one error type, a parser another, and an HTTP client a third.

Translate those errors where they cross into the application contract. Preserve underlying causes when diagnostics need them, but expose failure categories that callers can act on.

This does not mean flattening everything to strings. It means choosing which distinctions belong to the stable boundary.

Coherence is part of trait design

Rust's coherence and orphan rules prevent arbitrary overlapping implementations. They can surprise developers who first try to build highly generic extension APIs, but the restriction protects a key property: for a given trait/type combination, method resolution must remain unambiguous.

If you own neither the trait nor the type you want to implement it for, introduce a newtype:

struct MyRecord(ExternalRecord);
      
      impl Validator<MyRecord> for MyValidator {
          fn validate(&self, value: &MyRecord) -> Result<(), PipelineError> {
              // Domain-specific validation here.
              Ok(())
          }
      }
      

Newtypes are not mere boilerplate. They create an explicit semantic boundary and give your code ownership of the implementation surface.

Test the contract, not just the concrete type

Traits become valuable when multiple implementations obey the same behavioral expectations. A good test should assert those semantics:

#[test]
      fn sink_preserves_written_record() {
          let mut sink = VecSink::default();
          let record = Record {
              id: 7,
              email: "dev@example.com".into(),
              active: true,
          };
      
          sink.write(record.clone()).unwrap();
          assert_eq!(sink.0, vec![record]);
      }
      

For more complicated interfaces, build reusable contract tests that every implementation runs. That catches a common architectural failure: two types implement the same trait syntactically while disagreeing semantically.

An end-to-end test for the pipeline can remain small:

#[test]
      fn pipeline_validates_before_writing() {
          use std::collections::VecDeque;
      
          let valid = Record {
              id: 1,
              email: "a@example.com".into(),
              active: true,
          };
      
          let mut source = VecSource {
              records: VecDeque::from([valid.clone()]),
          };
          let validator = And(HasEmail, IsActive);
          let mut sink = VecSink::default();
      
          run(&mut source, &validator, &mut sink).unwrap();
          assert_eq!(sink.0, vec![valid]);
      }
      

Avoid abstraction for its own sake

Traits are not free architecture points. Every public trait creates a compatibility surface. Every generic parameter appears in compiler diagnostics. Every trait object creates an object-safety and lifetime boundary.

Before introducing one, ask:

  1. Are there multiple plausible implementations?
  2. Does the boundary separate responsibilities that evolve independently?
  3. Will callers benefit from substituting a test implementation?
  4. Can the contract be stated precisely enough to test?

If the answer is no, a concrete type may be better.

The design lesson

Rust gives several ways to express modularity, and they are useful precisely because they make trade-offs visible.

  • Traits define behavioral contracts.
  • Associated types assign one natural type choice to an implementation.
  • Generics give static composition and monomorphized execution.
  • Trait objects allow runtime composition behind a stable interface.
  • Newtypes work with coherence rules to define local semantic boundaries.
  • Domain errors keep dependency details from leaking across architecture layers.

The goal is not to maximize abstraction. It is to put abstraction exactly where change, testing, and ownership need a stable seam.