There is a point where “just inject the dependency” stops being the clean answer.

Not because injection is bad. It is usually the right default. It is explicit, traceable, and easy to test.

The problem shows up when you have genuinely independent components that need to coordinate, but direct calls would harden coupling. A feature starts depending on another feature, which depends on a third feature, which depends on something shared that none of them should own. Soon your dependency graph is not modeling architecture anymore. It is modeling history.

This is where many teams reach for a global event bus. That often makes things worse. The coupling does not disappear, it just becomes invisible. Debugging turns into “who is listening,” and ownership erodes.

The goal is different. The goal is a boundary that allows independent components to signal intent without learning who responds, while still keeping orchestration and mutation explicit.

That boundary is a scoped message channel.

What It Is, and What It Is Not

A message channel in this architecture is not a replacement for normal calls. Most interactions should still be direct. A channel is used when direct calls would create a dependency that you do not want the component to carry.

It is also not a global pubsub system. The channel is owned by the architecture root. It lives for the lifetime of that root. It is not a singleton. It is not “available everywhere.” It exists because AppContext decided it should exist.

The simplest mental model is: components can emit actions, orchestration decides what they mean.

The Capability Split

To keep the channel from becoming a backdoor, the API is intentionally asymmetric.

Most code only needs the ability to emit:

  • a narrow send(action) capability
  • fire-and-forget semantics

That is a feature, not a limitation. It prevents producers from writing workflows that rely on handler ordering or completion. It keeps them honest. They are emitting intent, not coordinating the system.

Orchestration can choose to do more. When AppContext needs strict sequencing, it can use an awaiting mode that waits until handlers have processed the message. This is not exposed as the default capability. It is an orchestration tool.

That split is how the channel stays a boundary rather than becoming a control plane.

Typed Actions, Scoped Handling

Actions are strongly typed. Handlers are registered per action type (plus an optional wildcard handler when you need cross-cutting behavior). This gives you a few practical benefits:

  • Producers can emit without knowing who listens.
  • Handlers stay focused and local to orchestration.
  • Adding a new action does not force a centralized “switch” to grow forever.
  • You can reason about routing by searching for handler registrations.

There is also an optional mapper step. It allows AppContext to normalize or transform actions before dispatch, which is useful for versioning, feature gating, instrumentation, or simply keeping producers small.

This is another reason ownership matters. If mapping exists, it should be owned where the system’s rules live, not sprinkled through producers.

Concurrency Without Chaos

A big reason event systems become fragile is concurrency. Handlers register at odd times. Messages dispatch while state is mid-mutation. Ordering assumptions sneak in. Race conditions appear as “flaky behavior.”

This is why the dispatcher is actor-isolated. Registration and dispatch happen behind a single concurrency boundary. Producers can emit freely, while routing remains deterministic and thread-safe.

The channel also supports two modes:

  • fire-and-forget delivery, where handlers run asynchronously
  • awaiting delivery, where the dispatcher awaits handler completion

Most of the time you want fire-and-forget. It keeps components decoupled.

Awaiting is for the cases where orchestration must sequence effects, such as when a later action assumes a prior one has completed. The key is that this choice belongs in orchestration, not in the producer.

The Hard Rule That Keeps It Sane

The channel does not mutate state.

Handlers do not live inside random components.

AppContext owns the channel instance, registers the handlers, and is the only place that mutates AppState in response to messages.

That rule is what prevents “event-driven” from turning into “nobody knows what changed state.”

When something happens, the path is still traceable:

  • a producer emitted an action
  • AppContext handled it
  • AppContext called services as needed
  • AppContext updated state

The channel loosened coupling without loosening ownership.

When to Use It

A simple test is to ask: if this component calls that component directly, is that a dependency you would be happy to live with for years?

If the answer is yes, call directly.

If the answer is no, but coordination is still required, emit an action across the channel and keep the responsibility for interpreting it in AppContext.

Used sparingly, a scoped channel becomes a pressure relief valve. It lets independent components communicate without deep injection and without turning the codebase into a global message swamp.

The next piece moves from mechanics to people: how these boundaries change team behavior, code review, onboarding, and delivery rhythm.