Skip to content

The IO seam — traits

Clinker reads and writes eight wildly different file formats (CSV, JSON, XML, fixed-width, EDIFACT, X12, HL7, SWIFT), yet the executor that pushes records through the DAG contains zero format-specific code. The mechanism that makes that possible is the engine’s most important seam: a single contract every format satisfies, so a new format plugs in without the execution loop changing by a character. You spent Data & Representation taking the data layer apart cell by cell; now we move up a layer to where new behaviour plugs in. The Rust tool underneath the seam is the trait; we use just enough of it to read the real FormatReader, and The Rust Book, ch. 10 is the canonical treatment if you want traits from first principles.

  • Read the FormatReader trait and name its two required trait methods and what a full drain looks like.
  • Explain how every format reaches the rest of the engine through that one contract, with zero format-specific code in the executor.
  • Write a trait, two implementors, and a function that drives any implementor through the trait: runnable Rust, the engine’s seam in miniature.
  • Distinguish a trait object (Box<dyn FormatReader>) from a concrete type, and say why the engine boxes readers and what dynamic dispatch costs here.

New terms in this lesson (each is also expanded inline at the point you first need it):

  • trait: a contract of methods, not a type.
  • trait method: a method the contract requires (or supplies by default).
  • impl: the block where a concrete type satisfies the trait.
  • seam: the plug-in boundary a trait creates.
  • trait object (Box<dyn FormatReader>): pre-taught where it appears below.

The motivating problem: one engine, eight formats

Section titled “The motivating problem: one engine, eight formats”

Clinker ships readers and writers for CSV, JSON, XML, fixed-width, EDIFACT, X12, HL7, and SWIFT. That’s eight wildly different ways to lay bytes on disk. Yet the executor, the part that pushes records through the DAG, contains zero format-specific code. It never asks “is this a CSV?” How can the same execution loop drive all eight?

The answer is a trait: a contract that says what a reader can do without fixing which reader it is. Every format implements the same two methods, and the engine talks only to the contract.

clinker-format ·traits.rs ·FormatReader trait @19acdcb4
/// Streaming record reader. Yields records one at a time.
pub trait FormatReader: Send {
fn schema(&mut self) -> Result<Arc<Schema>, FormatError>;
fn next_record(&mut self) -> Result<Option<Record>, FormatError>;
// ... plus default-bodied methods for multi-file / envelope handling
}

Two required trait methods carry the whole seam: ask for the schema, then pull records until next_record returns Ok(None). A FormatReader is exactly “a thing the engine can ask for a schema and then drain, one Record at a time.” Notice the return type of next_record, Result<Option<Record>, FormatError>: it’s the composed three-outcome type you read in Data & Representation, and the trait just promises every format produces it. The write side is its mirror image, consuming records one at a time and flushing:

clinker-format ·traits.rs ·FormatWriter trait @19acdcb4
/// Streaming record writer. Consumes records one at a time.
pub trait FormatWriter: Send {
fn write_record(&mut self, record: &Record) -> Result<(), FormatError>;
fn flush(&mut self) -> Result<(), FormatError>;
// ... plus default-bodied document-framing methods
}

Notice the supertrait bound : Send. The doc comment is explicit about why: a reader is moved onto the executor’s per-source ingest thread, so it must be Send. It is deliberately not Sync: a single reader is driven by one thread, streaming, never shared. That bound is a small architectural decision encoded in the type.

Strip the engine away and the pattern is small. A trait with one method, two implementors, and a function that works on any implementor. This is the same shape as FormatReader, shrunk to something you can run:

rust // editable

drain is the executor in miniature: it takes &mut dyn FormatReader and never learns whether it’s draining CSV or JSON, exactly what you predicted. That, and nothing more, is the seam doing its job: one function written against the contract, indifferent to which implementor it drives.

You just read the seam. Now you build one, scaffolded down to writing the whole thing yourself. Each rung adds one idea: first see a full trait + two impls + a driver; then supply one missing impl body; then write a fresh trait and its driver from a skeleton.

Worked: a trait, two impls, and a driver that drives both

Section titled “Worked: a trait, two impls, and a driver that drives both”

Here is a complete, runnable seam. A trait Counter with one required method, two unrelated structs that each impl it, and a total function that drives any Counter through the contract. Run it and watch one function tally two different structs.

rust // editable

total is drain again: one driver, written once against Counter, driving two structs it never names.

Below, the trait, the driver, and one implementor are done. The second implementor, EvenStream, which should yield 0, 2, 4, … up to a limit, is missing its next_count body. Fill in only that one method so the program drains cleanly to None.

rust // editable
💡 Hint 1
An implementor’s job is to honour the trait’s signature: return None when the source is exhausted, otherwise Some(value). Here “exhausted” means current has reached limit. Don’t forget to advance current so the next call makes progress.
Show solution
fn next_count(&mut self) -> Option<u32> {
if self.current >= self.limit {
return None;
}
let n = self.current;
self.current += 2;
Some(n)
}

total never changed. You taught a brand-new type to satisfy the same contract, and the driver drove it untouched; the seam absorbed the new implementor exactly the way the engine absorbs a new format.

Now with no method bodies given. Define a trait Source with one required method next_line(&mut self) -> Option<String>; give it two implementors, Fixed (yields lines from a Vec<String>) and Repeat (yields the same string times times), and a driver collect_all(&mut dyn Source) -> Vec<String> that drains any source into a Vec. The main is written for you and asserts the expected output.

rust // editable
💡 Hint 1
The driver doesn’t care which implementor it has: write it once against &mut dyn Source, looping while let Some(line) = src.next_line(). Each impl Source for … block supplies a next_line that returns None when its own source is exhausted.
Show solution
trait Source {
fn next_line(&mut self) -> Option<String>;
}
struct Fixed { lines: Vec<String> }
impl Source for Fixed {
fn next_line(&mut self) -> Option<String> {
self.lines.pop()
}
}
struct Repeat { text: String, times: u32 }
impl Source for Repeat {
fn next_line(&mut self) -> Option<String> {
if self.times == 0 { return None; }
self.times -= 1;
Some(self.text.clone())
}
}
fn collect_all(src: &mut dyn Source) -> Vec<String> {
let mut out = Vec::new();
while let Some(line) = src.next_line() {
out.push(line);
}
out
}

You wrote the seam end to end: one contract, two unrelated implementors, one driver that drives either. Scale next_line up to next_record, swap your two structs for eight format readers, and you have FormatReader and the executor’s drain loop. The shape does not change.

The format a job uses isn’t known when the engine is compiled; it’s chosen at run time, from the plan. So the executor needs a single variable that can hold any reader. That’s a trait object: Box<dyn FormatReader>, a heap-allocated value plus a hidden pointer to a vtable (the table of “which next_record does this actual reader use?”). Calling through it is dynamic dispatch: the concrete method is looked up at run time.

In real clinker, the concrete readers are themselves generic structs: CsvReader<R>, FixedWidthReader<R>, and so on, generic over the byte source R. They’re built type-specifically, then immediately boxed into a trait object at one factory function so that everything downstream is uniform:

clinker-exec ·mod.rs ·RecordSource trait @19acdcb4
// crates/clinker-exec/src/executor/ingest.rs — the dispatch boundary
fn build_format_reader(/* … */) -> Box<dyn FormatReader> {
match &input.format {
InputFormat::Csv(opts) => Box::new(CsvReader::new(/* … */)),
InputFormat::Json(opts) => Box::new(JsonReader::new(/* … */)),
// ... one arm per format, each boxed into the same trait-object type
}
}

This is worth pausing on, because it’s the shape of every plug-in seam in clinker: a typed enum (InputFormat) is matched once at the boundary, each arm constructs the right concrete reader, and all of them collapse into one Box<dyn FormatReader>. There is no string-keyed “format registry”: dispatch is over the closed enum you met in Data & Representation, so an unknown format is a plan-time error, not a runtime lookup miss.

What does dyn cost? One pointer-indirection per call. Here that’s invisible: a vtable call is dwarfed by the file IO and parsing that produce each record. Dynamic dispatch is the right trade exactly where the set of types is open-ended and chosen at run time, and the per-call cost is noise against the work being dispatched. The next lesson, One reader, every format, shows the opposite choice, generics, for the field-read hot path where that same pointer-indirection would not be noise.

One step further: the transport generalization

Section titled “One step further: the transport generalization”

FormatReader assumes bytes; it decodes a stream. But a SQL SELECT cursor yields rows with no byte body at all. So one crate up, the executor defines a broader contract, RecordSource, and bridges the file case to it with a single blanket impl:

crates/clinker-exec/src/source/mod.rs
impl RecordSource for Box<dyn FormatReader> {
// a file transport reaches the row-oriented seam by wrapping its byte reader
}

You don’t need the details yet; Execution & Memory returns to it. The point is the layering: a narrow, byte-oriented trait (FormatReader) nested inside a broader, transport-agnostic one (RecordSource), each a clean seam. Traits compose into layers the same way types do.

You’ve seen the engine’s open seam: a trait, implemented per format, boxed into a trait object at one boundary so the rest of the engine stays format-blind. You also built one from scratch (a contract, two implementors, one driver) which is the same shape, scaled down. Next, One reader, every format: the other dispatch strategy, generics, and why the field-read hot path makes the opposite choice.

Go deeper on the Rust (optional, one-directional, for traits taught from first principles in The Rust Book):

Glossary terms used: trait, trait method, impl, seam, trait object.