2026-03-30

dotnet · channels

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.

PieceRole
ChannelWriter<T>Producers write, and they complete or fail on purpose
ChannelReader<T>Consumers read, usually ReadAllAsync
BoundedBackpressure when the producer is faster
UnboundedMemory 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:

  1. An unbounded channel absorbs the spike until the process dies.
  2. If the writer never calls Complete, the reader waits forever.
  3. Sync-over-async on the edge defeats the point. Keep the boundary async.
  4. 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.

All posts