Concept: The Source Trait & the E/T/L Boundary
Kind: Concept. Seam installed this arc: the
Sourcetrait + the Extract / Transform / Load split.
The idea
An ETL job does three things, and the whole discipline of this course is keeping them three separate things:
- Extract (E) — talk to the outside world and come back with raw bytes. This is where the network lives: HTTP, timeouts, 429s, auth. It is
async, it is fallible, and it knows nothing about what the bytes mean. - Transform (T) — turn those raw bytes into your normalized domain types. This is pure: no network, no clock, no files. Same input, same output, every time. It is where parsing — and therefore validation — happens.
- Load (L) — write the normalized rows somewhere durable. For us that is a JSONL file; later it could be a database. It knows nothing about where the rows came from.
Draw the boundaries as types and each stage becomes independently testable, independently swappable, and — the payoff four arcs from now — independently schedulable. The orchestrator will hold a Source, a Transform, and a Sink as trait objects and run them in order, never knowing it is talking to CelesTrak versus Space-Track. That only works if the three stages never leak into each other.
The two typed boundaries are the load-bearing part. Between E and T sits a RawRecord (bytes plus provenance). Between T and L sits a Row (a normalized domain value). Everything upstream of a boundary can change without touching anything downstream, because the boundary is a type, not a convention.
external API RawRecord Row file
│ │ │ │
▼ E ▼ T ▼ L ▼
Source ───────────► (raw bytes) ──────► (domain) ──────► Sink
async, fallible pure parse durable write
The three traits, exactly as etl-core declares them
Here are the real signatures you will implement. Read them as the contract for the whole pipeline:
// crates/etl-core/src/pipeline.rs
// (needs async-trait + chrono + serde; not playground-safe — see the toy below
// for a runnable version of the same shape)
use async_trait::async_trait;
/// Raw bytes fetched from a source, before any parsing — the output of E.
#[derive(Debug, Clone, PartialEq)]
pub struct RawRecord {
pub source: String,
pub payload: String,
pub fetched_at: DateTime<Utc>,
}
/// A normalized domain row — the typed boundary between T and L.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Row {
SpaceObject(SpaceObject),
Conjunction(Conjunction),
Launch(Launch),
}
/// **E** — extract raw records from an external source.
#[async_trait]
pub trait Source: Send + Sync {
fn name(&self) -> &str;
async fn extract(&self) -> Result<Vec<RawRecord>, EtlError>;
}
/// **T** — parse raw records into normalized rows. Pure and synchronous.
pub trait Transform: Send + Sync {
fn transform(&self, raw: &RawRecord) -> Result<Vec<Row>, EtlError>;
}
/// **L** — persist normalized rows to a sink; returns the count written.
#[async_trait]
pub trait Sink: Send + Sync {
async fn load(&self, rows: &[Row]) -> Result<usize, EtlError>;
}
Four details in those signatures carry the whole design; notice each one:
Source::extractisasync;Transform::transformis not. The network lives in E and only in E. T is a plain synchronous function because parsing bytes needs no runtime — which is exactly why it is trivial to unit-test with a hand-writtenRawRecordand no server at all.- Every method returns
Result<_, EtlError>. One error type flows through the whole pipeline. The classification you built in Part I (is_transient()) is what the later retry loop reads off these results. - All three traits are
Send + Sync. That is the price of admission to being scheduled across threads on a tokio runtime. The orchestrator will move these across.awaitpoints and hold them behindArc; withoutSend + Syncthat would not compile. Rowis one enum over every entity, tagged bykind. This is what lets a singleSinkaccept the output of any pipeline. We will come back to why the tag matters.
A runnable toy: the receipt fetcher
The trait objects above pull in async-trait, chrono, and serde, so they will not run on the playground. Here is the same shape on a domain that cannot be mistaken for the answer key — a corner shop's receipts — reduced to synchronous std so it runs anywhere. Fetch raw text → parse to typed rows → store; three traits, two typed boundaries:
// The raw bytes a source hands off — the output of E, before any parsing. #[derive(Debug, Clone)] struct RawReceipt { store: String, payload: String, // one "ITEM<TAB>CENTS" line per purchase } // The normalized row — the typed boundary between T and L. #[derive(Debug, Clone, PartialEq)] struct LineItem { item: String, cents: u32, } // E — pull raw text from somewhere external. trait Fetch { fn fetch(&self) -> RawReceipt; } // T — parse raw text into normalized rows. Pure and total over its input. trait Parse { fn parse(&self, raw: &RawReceipt) -> Vec<LineItem>; } // L — persist normalized rows; report how many landed. trait Store { fn store(&mut self, rows: &[LineItem]) -> usize; } struct StubShop; impl Fetch for StubShop { fn fetch(&self) -> RawReceipt { RawReceipt { store: "corner-shop".into(), payload: "coffee\t450\nbagel\t325\n".into(), } } } struct ReceiptParser; impl Parse for ReceiptParser { fn parse(&self, raw: &RawReceipt) -> Vec<LineItem> { raw.payload .lines() .filter(|l| !l.is_empty()) .map(|l| { let (item, cents) = l.split_once('\t').expect("well-formed line"); LineItem { item: item.to_string(), cents: cents.parse().expect("numeric cents") } }) .collect() } } struct MemStore { rows: Vec<LineItem>, } impl Store for MemStore { fn store(&mut self, rows: &[LineItem]) -> usize { self.rows.extend_from_slice(rows); rows.len() } } fn main() { // The pipeline names only the three seams, never the concrete shop. let source = StubShop; let transform = ReceiptParser; let mut sink = MemStore { rows: vec![] }; let raw = source.fetch(); // E let rows = transform.parse(&raw); // T let n = sink.store(&rows); // L println!("fetched from {}", raw.store); println!("parsed {} rows", rows.len()); println!("stored {n} rows"); println!("first: {:?}", sink.rows[0]); }
fetched from corner-shop
parsed 2 rows
stored 2 rows
first: LineItem { item: "coffee", cents: 450 }
main names Fetch, Parse, and Store — never StubShop by knowing what a corner shop is. Swap in a SupermarketApi that implements Fetch and the parse/store code does not change one character. That is the seam, in miniature.
Why Row is one tagged enum
Look again at the Sink signature: async fn load(&self, rows: &[Row]) -> …. One method, one row type — yet CelesTrak emits space objects, Space-Track emits conjunctions, and TheSpaceDevs emits launches. If each source had its own row type, you would need a different Sink per source, and the orchestrator could not treat them uniformly.
So Row is a single enum over all of them, and the #[serde(tag = "kind", …)] attribute makes each variant serialize with a discriminant field on the wire:
use serde::{Serialize, Deserialize}; #[derive(Serialize, Deserialize, Debug, PartialEq)] #[serde(tag = "kind", rename_all = "snake_case")] enum Row { SpaceObject { norad_id: u32 }, Launch { name: String }, } fn main() { let r = Row::Launch { name: "Starlink G-99".into() }; let json = serde_json::to_string(&r).unwrap(); println!("{json}"); // Round-trips: the tag is how the reader knows which variant to build. let back: Row = serde_json::from_str(&json).unwrap(); println!("{}", back == r); }
{"kind":"launch","name":"Starlink G-99"}
true
The "kind":"launch" field is not decoration. It is how the downstream reader — panoptes-gen — knows which variant to reconstruct from a line of the file without out-of-band knowledge of which source wrote it. Untagged, {"name": "Starlink G-99"} would be ambiguous the moment two variants shared a field shape. The tag is the contract that lets one file hold rows from every pipeline.
Transform returns Row and Row's fields are your domain types (a NoradId, an OrbitClass enum, not bare strings), a successful transform is proof the data is well-formed. An off-codebook orbit class cannot survive parsing — it fails to deserialize rather than sitting in the file as a bad string. Validation is not a separate stage; it is the type of the T→L boundary.
Object safety: why the orchestrator can schedule a dyn Source
The orchestrator (Part V) does not know about CelestrakSource. It holds a Box<dyn Source> — a source with its concrete type erased behind a vtable — and calls .extract() through that pointer. For that to compile, Source must be dyn-compatible (the current name for "object-safe"): every method must be callable through a vtable.
The real Source::extract is async, which normally is not dyn-compatible — but #[async_trait] rewrites it into a method returning a boxed future, a single concrete signature a vtable can hold. That is the whole reason the trait carries the attribute. Here is the boxed-source pattern working (same shape as a_source_can_be_boxed_as_dyn in the real test suite):
// deps: async-trait = "0.1", tokio = { version = "1", features = ["full"] }
use async_trait::async_trait;
#[derive(Debug)]
struct RawReceipt { store: String, payload: String }
#[async_trait]
trait Fetch: Send + Sync {
fn name(&self) -> &str;
async fn fetch(&self) -> RawReceipt;
}
struct StubShop;
#[async_trait]
impl Fetch for StubShop {
fn name(&self) -> &str { "corner-shop" }
async fn fetch(&self) -> RawReceipt {
RawReceipt { store: self.name().to_string(), payload: "coffee\t450\n".into() }
}
}
#[tokio::main]
async fn main() {
// The orchestrator holds exactly this: a source with its type erased.
let source: Box<dyn Fetch> = Box::new(StubShop);
let raw = source.fetch().await;
println!("{} -> {:?}", source.name(), raw.store);
}
corner-shop -> "corner-shop"
The trap: a generic method silently makes the trait un-schedulable
Object safety is easy to break by accident, and the error only shows up at the Box<dyn …> line — far from the method that caused it. Add one generic method and the whole trait can no longer be a trait object:
trait Source { // The generic `T` is the poison: each call could pick a different T, // so there is no single function pointer to put in a vtable. fn extract_as<T: From<String>>(&self) -> T; } fn main() { // The error lands HERE, not on the method above. let _boxed: Box<dyn Source> = todo!(); }
error[E0038]: the trait `Source` is not dyn compatible
note: for a trait to be dyn compatible it needs to allow building a vtable
That E0038 is the compiler telling you the orchestrator would never be able to hold this trait. The fix is to keep trait methods monomorphic — take &self and concrete arguments, return concrete types — which is exactly why Source, Transform, and Sink have the plain, generic-free signatures they do. Schedulability is a property you design in from the first line, by keeping the trait dyn-compatible.
The one-for-one mapping
Everything in the receipt toy maps exactly onto what you are about to build, with the domain swapped and the network made real. RawReceipt becomes RawRecord { source, payload, fetched_at }; LineItem becomes the tagged Row enum; Fetch::fetch (sync) becomes Source::extract (async, returning Result<Vec<RawRecord>, EtlError>); Parse::parse becomes Transform::transform (&RawRecord → Vec<Row>, still pure); Store::store becomes Sink::load (async, appends JSONL, returns the count). The concrete StubShop becomes CelestrakSource { base_url, group }, ReceiptParser becomes CelestrakTransform (3-line TLE blocks → Row::SpaceObject), and MemStore becomes JsonlSink { path }. And where the toy's main named only the three traits, the orchestrator names only Box<dyn Source>, dyn Transform, and dyn Sink. The shape is identical; only the domain and the I/O change — learn the shape here and the CelesTrak build is filling in a template you already understand.
Questions to lock
- Which stage is
asyncand which is pure, and why is that split what makes the transform trivially unit-testable? - What are the two typed boundaries in the pipeline, and what type sits on each?
- Why is
Rowa single enum tagged bykindrather than one row type per source? What downstream problem does the tag solve? - What does "dyn-compatible" mean, and which one change to a trait's method would make the orchestrator unable to hold a
Box<dyn Source>? - Where does validation happen in E/T/L, and why is a successful
Transforma proof of well-formedness rather than a step you run afterward?
The graded quiz for this arc is at the end of the part, on the Concept-Check: Extraction page.