InterviewKata
Architecture styles · 11 of 16 · Moving data and messages

Pipes and filters

Pipes and filters turns processing into a line of small, independent steps. This page builds Pedal's monthly city report from six filters, shows why every clean-up must come before the count, and lets you rearrange the pipeline to see which mistakes it catches and which it lets through.

See the patternClose cousins at class level: 17 Chain of responsibility · 07 Decorator.

1. In one picture

A car wash tunnel: the car rolls through pre-soak, brushes, rinse, wax and dryer. Each station does one thing, doesn't care what happened before as long as a wet car arrives, and can be replaced or added without rebuilding the tunnel. The conveyor between stations is the only thing they share.

Pipes and filters splits processing into a chain of independent steps. Each filter reads input, does one transformation and writes output; pipes carry the data from one filter to the next. Unix shell commands (grep | sort | uniq -c) are the classic example; modern versions are data pipelines, stream processors, compilers and image processing chains.

2. Pedal built this way

Every month the city wants rides and minutes per district, with test rides removed, abandoned bikes capped at 12 hours, and no rider names. Follow the four records through the chain.

3. The rules

RuleWhy it existsBroken when…
Each filter does one transformationSteps are easy to test, reuse and replace.A "process_everything" step that cleans, joins and formats.
Filters share no stateAny step can be moved, copied or run elsewhere.Two filters coordinate through a global counter.
The data shape between steps is a contractA step can be swapped for any other that honours it.A step quietly renames a field the next one needs.
Order is explicit and meaningfulResults depend on order; it must be visible in one place.Test rides are counted because filtering happened after aggregation.

4. How it goes wrong

Steps that need the whole datasetA sort or aggregate holds everything in memory and blocks streaming; know which steps do this.
Errors that stop the lineOne malformed record kills a nightly run; send bad records to a side output instead.
Too many tiny stepsSerialising between dozens of trivial filters costs more than the work they do.
Interactive work in a pipelinePipes suit flows in one direction; request-and-reply conversations need another style.

5. The code

Six filters, each a small generator over a stream of records: drop test rides, cap abandoned rides at 12 hours, add the district, remove names, count per district, format as CSV. The pipeline function just feeds each filter's output to the next.

from __future__ import annotations
from typing import Callable, Iterable, Iterator

Record = dict
Filter = Callable[[Iterable[Record]], Iterator[Record]]

DISTRICT = {"D12": "Centre", "D40": "Harbour"}


# Each filter does one job, reads a stream and writes a stream. None knows about the others.
def drop_test_rides(rows: Iterable[Record]) -> Iterator[Record]:
    return (r for r in rows if not r["test"])


def cap_minutes(rows: Iterable[Record]) -> Iterator[Record]:
    return ({**r, "minutes": min(r["minutes"], 720)} for r in rows)   # forgotten bikes count as 12 hours


def add_district(rows: Iterable[Record]) -> Iterator[Record]:
    return ({**r, "district": DISTRICT[r["dock"]]} for r in rows)


def anonymise(rows: Iterable[Record]) -> Iterator[Record]:
    return ({k: v for k, v in r.items() if k != "rider"} for r in rows)


def count_by_district(rows: Iterable[Record]) -> Iterator[Record]:
    totals: dict[str, list[int]] = {}
    for r in rows:
        t = totals.setdefault(r["district"], [0, 0])
        t[0] += 1
        t[1] += r["minutes"]
    return ({"district": d, "rides": n, "minutes": m} for d, (n, m) in sorted(totals.items()))


def to_csv(rows: Iterable[Record]) -> Iterator[Record]:
    yield {"line": "district,rides,minutes"}
    for r in rows:
        yield {"line": f"{r['district']},{r['rides']},{r['minutes']}"}


def pipeline(source: Iterable[Record], *filters: Filter) -> Iterator[Record]:
    stream: Iterable[Record] = source
    for f in filters:                       # the pipe: each filter's output feeds the next
        stream = f(stream)
    return iter(stream)


if __name__ == "__main__":
    rides = [{"id": "R1", "rider": "Mia", "minutes": 42, "dock": "D12", "test": False},
             {"id": "R2", "rider": "Leo", "minutes": 15, "dock": "D12", "test": False},
             {"id": "R3", "rider": "qa-bot", "minutes": 5, "dock": "D12", "test": True},
             {"id": "R4", "rider": "Ana", "minutes": 900, "dock": "D40", "test": False}]
    steps = [drop_test_rides, cap_minutes, add_district, anonymise, count_by_district, to_csv]
    for row in pipeline(rides, *steps):
        print(row["line"])
    print("pipeline:", " | ".join(f.__name__ for f in steps))
type Rec = Record<string, string | number | boolean>;
type Filter = (rows: Iterable<Rec>) => Iterable<Rec>;
const DISTRICT: Record<string, string> = { D12: "Centre", D40: "Harbour" };

// Each filter does one job, reads a stream and writes a stream. None knows about the others.
function* dropTestRides(rows: Iterable<Rec>): Iterable<Rec> { for (const r of rows) if (!r.test) yield r; }
function* capMinutes(rows: Iterable<Rec>): Iterable<Rec> { for (const r of rows) yield { ...r, minutes: Math.min(r.minutes as number, 720) }; } // forgotten bikes count as 12 hours
function* addDistrict(rows: Iterable<Rec>): Iterable<Rec> { for (const r of rows) yield { ...r, district: DISTRICT[r.dock as string] }; }
function* anonymise(rows: Iterable<Rec>): Iterable<Rec> { for (const r of rows) { const { rider: _drop, ...rest } = r; void _drop; yield rest; } }
function* countByDistrict(rows: Iterable<Rec>): Iterable<Rec> {
  const totals = new Map<string, [number, number]>();
  for (const r of rows) { const t = totals.get(r.district as string) ?? [0, 0]; t[0] += 1; t[1] += r.minutes as number; totals.set(r.district as string, t); }
  for (const d of [...totals.keys()].sort()) { const [n, m] = totals.get(d) as [number, number]; yield { district: d, rides: n, minutes: m }; }
}
function* toCsv(rows: Iterable<Rec>): Iterable<Rec> {
  yield { line: "district,rides,minutes" };
  for (const r of rows) yield { line: `${r.district},${r.rides},${r.minutes}` };
}

// The pipe: each filter's output feeds the next.
const pipeline = (source: Iterable<Rec>, ...filters: [string, Filter][]): Iterable<Rec> => filters.reduce((s, [, f]) => f(s), source);

const rides: Rec[] = [
  { id: "R1", rider: "Mia", minutes: 42, dock: "D12", test: false },
  { id: "R2", rider: "Leo", minutes: 15, dock: "D12", test: false },
  { id: "R3", rider: "qa-bot", minutes: 5, dock: "D12", test: true },
  { id: "R4", rider: "Ana", minutes: 900, dock: "D40", test: false },
];
const steps: [string, Filter][] = [["drop_test_rides", dropTestRides], ["cap_minutes", capMinutes], ["add_district", addDistrict],
  ["anonymise", anonymise], ["count_by_district", countByDistrict], ["to_csv", toCsv]];
for (const row of pipeline(rides, ...steps)) console.log(row.line);
console.log("pipeline: " + steps.map(([n]) => n).join(" | "));
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class Main {
    static final Map<String, String> DISTRICT = Map.of("D12", "Centre", "D40", "Harbour");
    record Step(String name, Function<Stream<Map<String, Object>>, Stream<Map<String, Object>>> f) {}

    static Map<String, Object> with(Map<String, Object> r, String k, Object v) { Map<String, Object> m = new HashMap<>(r); m.put(k, v); return m; }

    // Each filter does one job, reads a stream and writes a stream. None knows about the others.
    static Stream<Map<String, Object>> dropTestRides(Stream<Map<String, Object>> rows) { return rows.filter(r -> !(Boolean) r.get("test")); }
    static Stream<Map<String, Object>> capMinutes(Stream<Map<String, Object>> rows) { return rows.map(r -> with(r, "minutes", Math.min((Integer) r.get("minutes"), 720))); } // forgotten bikes count as 12 hours
    static Stream<Map<String, Object>> addDistrict(Stream<Map<String, Object>> rows) { return rows.map(r -> with(r, "district", DISTRICT.get((String) r.get("dock")))); }
    static Stream<Map<String, Object>> anonymise(Stream<Map<String, Object>> rows) { return rows.map(r -> { Map<String, Object> m = new HashMap<>(r); m.remove("rider"); return m; }); }
    static Stream<Map<String, Object>> countByDistrict(Stream<Map<String, Object>> rows) {
        Map<String, int[]> totals = new TreeMap<>();
        rows.forEach(r -> { int[] t = totals.computeIfAbsent((String) r.get("district"), k -> new int[2]); t[0]++; t[1] += (Integer) r.get("minutes"); });
        List<Map<String, Object>> out = new ArrayList<>();
        totals.forEach((d, t) -> out.add(Map.of("district", d, "rides", t[0], "minutes", t[1])));
        return out.stream();
    }
    static Stream<Map<String, Object>> toCsv(Stream<Map<String, Object>> rows) {
        return Stream.concat(Stream.of(Map.<String, Object>of("line", "district,rides,minutes")),
            rows.map(r -> Map.<String, Object>of("line", r.get("district") + "," + r.get("rides") + "," + r.get("minutes"))));
    }

    // The pipe: each filter's output feeds the next.
    static Stream<Map<String, Object>> pipeline(Stream<Map<String, Object>> source, List<Step> steps) {
        Stream<Map<String, Object>> s = source;
        for (Step st : steps) s = st.f().apply(s);
        return s;
    }

    static Map<String, Object> ride(String id, String rider, int minutes, String dock, boolean test) {
        Map<String, Object> m = new HashMap<>();
        m.put("id", id); m.put("rider", rider); m.put("minutes", minutes); m.put("dock", dock); m.put("test", test);
        return m;
    }

    public static void main(String[] args) {
        List<Map<String, Object>> rides = List.of(ride("R1", "Mia", 42, "D12", false), ride("R2", "Leo", 15, "D12", false),
            ride("R3", "qa-bot", 5, "D12", true), ride("R4", "Ana", 900, "D40", false));
        List<Step> steps = List.of(new Step("drop_test_rides", Main::dropTestRides), new Step("cap_minutes", Main::capMinutes), new Step("add_district", Main::addDistrict),
            new Step("anonymise", Main::anonymise), new Step("count_by_district", Main::countByDistrict), new Step("to_csv", Main::toCsv));
        pipeline(rides.stream(), steps).forEach(r -> System.out.println(r.get("line")));
        System.out.println("pipeline: " + steps.stream().map(Step::name).collect(Collectors.joining(" | ")));
    }
}

Output (the same in all three languages)

district,rides,minutes
Centre,2,57
Harbour,1,720
pipeline: drop_test_rides | cap_minutes | add_district | anonymise | count_by_district | to_csv

6. Try it

Build the pipeline

Turn steps on and off and move them up and down. Each step checks that it received the shape it expects.


      

7. What it costs, and when to pick another style

What you get

  • Simple, testable steps: each filter is a small function with clear input and output.
  • Reuse and recombination: new reports reuse old steps in a new order.
  • Parallelism: slow steps run as several copies; steps can stream.
  • Easy to extend: a new requirement is often a new filter in the chain.

What you pay

  • One direction only: no back-and-forth conversation between steps.
  • Serialisation overhead: each pipe may encode and decode data.
  • Whole-dataset steps break streaming: sorts and joins hold everything.
  • Error handling across steps: partial failures need a plan for retries and bad records.
If…Consider instead
Parts must react to events independently, not in a fixed lineEvent-driven
Request and reply with a user waitingClient-server
Huge data, many machinesA distributed dataflow engine (Spark, Flink, Beam): pipes and filters at scale
Steps vary per customer or regionFilters as plugins in a microkernel

8. Signs you've outgrown it

9. Remember it and say it

Memory hook: "The car wash"Data flows through independent steps that each do one job, connected by pipes.
How this hook works: what “The car wash” means

What the slogan means, phrase by phrase

“The car wash”
A fixed sequence of stations.
“each does one job”
Filters: small, independent, swappable.

Why this picture fits: A car wash tunnel: pre-soak, brushes, rinse, wax, dry; a new station can be added without rebuilding the tunnel.

The story → the concept

The car washPedal
The carThe month's ride records
StationsDrop tests, cap minutes, add district, anonymise, count, format
The conveyorThe pipe between steps
Adding a wax stationAdding anonymise without touching the others

Watch it on an example

  1. Test rides are dropped before counting.
  2. Ana's forgotten bike counts as 720 minutes, not 900.
  3. The CSV has districts and totals, never names.

The trap it saves you from

Aggregating before filtering, so wrong records end up inside totals where no later step can remove them.

It still works when…

Inside other styles: a serverless function chain or a stream processor is often a pipeline.

In an interview or design review

10. Check yourself

What do two filters in a pipeline share?

That's what makes them independent and replaceable.

Test rides appear in the city's totals. Which ordering mistake is likely?

Once records are totals, no later filter can remove them.

Which kind of step stops a pipeline from streaming?

It must see everything before it can emit anything.

When is pipes and filters a poor fit?

Request and reply is a conversation, not a one-way flow.

11. Go deeper

ResourceWhy
Architecture 16a: Pipeline and serverlessHow an architect evaluates the style.
System design 23: Batch and stream processingPipelines at scale: batch jobs and stream processors.
Pipeline (Unix)The original pipes and filters, and their history.