Actor Pipeline

Chain processing stages together: each stage transforms data and passes it to the next. A fundamental pattern in stream processing, ETL, and request middleware.

Hew

Each actor holds a typed reference to the next stage. The pipeline topology is visible in the actor definitions — each let next: ActorType field declares exactly where messages go. Stages call next.process() with method-call syntax.

pipeline.hew
actor Logger {
    receive fn log(token: Int) {
        println(f"Logger: token {token} reached end of pipeline");
    }
}
actor Tripler {
    let next: Logger;
    receive fn process(token: Int) {
        let result = token * 3;
        next.log(result);
    }
}
actor Adder {
    let amount: Int;
    let next: Tripler;
    receive fn process(token: Int) {
        let result = token + amount;
        next.process(result);
    }
}
actor Doubler {
    let next: Adder;
    receive fn process(token: Int) {
        let result = token * 2;
        next.process(result);
    }
}
fn main() {
    // Build pipeline: Doubler -> Adder(+10) -> Tripler -> Logger
    let logger = spawn Logger;
    let tripler = spawn Tripler(next: logger);
    let adder = spawn Adder(amount: 10, next: tripler);
    let doubler = spawn Doubler(next: adder);
    // Token 5: 5 -> *2=10 -> +10=20 -> *3=60 -> log
    doubler.process(5);
    sleep_ms(200);
    // Can inject mid-pipeline
    adder.process(100);
    sleep_ms(200);
}

Go

Go pipelines use channels: each stage reads from an input channel and writes to an output channel. You must wire channels between goroutines, close them when done, and handle shutdown propagation. The topology lives in main(), not in the stage definitions.

pipeline.go
func doubler(in <-chan int, out chan<- int) {
    for token := range in {
        out <- token * 2
    }
    close(out)
}

func adder(amount int, in <-chan int, out chan<- int) {
    for token := range in {
        out <- token + amount
    }
    close(out)
}

func tripler(in <-chan int, out chan<- int) {
    for token := range in {
        out <- token * 3
    }
    close(out)
}

func logger(in <-chan int, done chan<- bool) {
    for token := range in {
        fmt.Printf("Logger: token %d reached end\n", token)
    }
    done <- true
}

func main() {
    // Wire 4 channels to connect 4 stages
    ch0 := make(chan int) // input -> doubler
    ch1 := make(chan int) // doubler -> adder
    ch2 := make(chan int) // adder -> tripler
    ch3 := make(chan int) // tripler -> logger
    done := make(chan bool)

    go doubler(ch0, ch1)
    go adder(10, ch1, ch2)
    go tripler(ch2, ch3)
    go logger(ch3, done)

    // Token 5: 5 -> *2=10 -> +10=20 -> *3=60 -> log
    ch0 <- 5
    close(ch0) // Must close or downstream blocks forever
    <-done
}

What this shows

Developer experience

In Hew, the pipeline topology is encoded in the type system: Doubler holds let next: Adder, so the compiler knows the wiring at compile time. In Go, channel wiring happens at runtime in main() — nothing prevents you from connecting the wrong channels or passing them in the wrong order.

Mid-pipeline injection

In Hew, because each actor has a stable identity, you can send messages directly to any stage: adder.process(100) bypasses the doubler and enters the pipeline at the adder. In Go, mid-pipeline injection requires either exposing the intermediate channel or adding a separate input path.

Shutdown

Go pipelines require propagating channel closes through every stage. Missing a single close() causes the downstream goroutine to block forever. Hew actors have independent lifecycles — each stage processes its mailbox independently, and there is no close propagation to manage.

Trade-offs

Go's channel pipelines are more flexible: stages can be rewired at runtime, and channels can be shared or multiplexed. Hew's typed actor references lock the topology at spawn time, which prevents runtime reconfiguration but eliminates mis-wiring bugs entirely.