2026-03-30
A channel in DI is a pipe
People hear "inject a Channel<T>" and assume it is cleverness. It is a pipe the host already understands.
System.Threading.Channels is the producer/consumer primitive for async code. Bounded or unbounded, an explicit completion, a reader and a writer. Stephen Toub's introduction is the one I send people. Bounded options, including what happens when the channel is full, are on BoundedChannelOptions.
| Piece | Role |
|---|---|
ChannelWriter<T> | Producers write, and they complete or fail on purpose |
ChannelReader<T> | Consumers read, usually ReadAllAsync |
| Bounded | Backpressure when the producer is faster |
| Unbounded | Memory is the throttle. That is how you get an OutOfMemoryException |
The lifetime is the part DI adds. The same rules as any other service.
- Singleton. One bus for the process. Fine for in-process pub/sub.
- Scoped. One pipe per scope, when the work should not leak.
- Transient readers and writers, and no stable channel. Usually an accident.
Frank.Channels.DependencyInjection is the registration I got tired of copy-pasting. You name the channel and the lifetime. You can inject the channel, the reader, or the writer.
It is not a message broker. It is not Kafka in the process. Channels everywhere, because channels are cool, is spaghetti that happens to be async.
What bites:
- An unbounded channel absorbs the spike until the process dies.
- If the writer never calls
Complete, the reader waits forever. - Sync-over-async on the edge defeats the point. Keep the boundary async.
- A singleton holding a scoped producer leaks the scope, or starves the work.
I packaged it because the same three lines kept showing up, and copy-paste is how a subtle bug becomes tradition.
If the API feels boring, it is working. The interesting part is what you push through the pipe.