Skip to main content

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 handle to the next stage. The pipeline topology is visible in the actor definitions — each let next: Stage field declares exactly where messages go. Stages call next.process() with method-call syntax.

pipeline.hew
actor Logger {
    receive fn log(token: i64) {
        println(f"Logger: token {token} reached end of pipeline");
    }
}
 
actor Tripler {
    let next: Logger;
 
    receive fn process(token: i64) {
        let result = token * 3;
        next.log(result).expect("actor stopped");
    }
}
 
actor Adder {
    let amount: i64;
    let next: Tripler;
 
    receive fn process(token: i64) {
        let result = token + amount;
        next.process(result).expect("actor stopped");
    }
}
 
actor Doubler {
    let next: Adder;
 
    receive fn process(token: i64) {
        let result = token * 2;
        next.process(result).expect("actor stopped");
    }
}
 
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).expect("actor stopped");
    sleep(200ms);
 
    // Can inject mid-pipeline
    adder.process(100).expect("actor stopped");
    sleep(200ms);
}

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 often happens at runtime in main(); type signatures catch element types, while topology mistakes are usually design/test concerns.

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 close() or cancellation path can leave downstream goroutines waiting. Hew actors have independent lifecycles — each stage processes its mailbox independently, but actor shutdown and cancellation still need explicit design in larger systems.

Trade-offs

Go's channel pipelines support stages that are rewired at runtime and channels that are shared or multiplexed. Hew's typed actor references fix this example's topology at spawn time, so wiring is checked through actor field types while runtime reconfiguration requires a different design.