Channel Abstraction¶
Yaya reaches users over four different messaging surfaces — WhatsApp, SMS, Telegram, and a PWA — each backed by a different third-party package with its own API shape [@channel-type-enum]. The channel abstraction is the contract that keeps the rest of the codebase from having to know which of those four it is talking to. Any code that wants to send a message — an alert dispatcher, a broadcast job, an onboarding flow — depends only on ChannelInterface, not on WhatsAppChannel or TelegramChannel directly, so the same call site works unchanged regardless of which channel a given user is reachable on.
The contract¶
ChannelInterface defines four methods every channel implementation must provide: getType() returns the channel's ChannelTypeEnum identity; send() sends a message to a known User; sendToIdentifier() sends to a raw identifier (such as a phone number) without requiring a User row, which matters for reaching unregistered recipients; and normalizeIdentifier() canonicalizes an identifier for that channel, such as formatting a phone number [@channel-interface]. InboundChannelInterface extends this with parseInboundPayload() for channels that also receive messages, not just send them. Because every channel implementation honors the same four-method contract, a caller holding a ChannelInterface never needs a conditional branch per channel type — it calls send() and the implementation underneath handles WhatsApp's Cloud API, Telegram's Nutgram-based bot, or the SMS gateway differently, but transparently.
Registry and resolution¶
Two small classes turn the interface into a working system. ChannelManager is a plain in-memory registry: implementations call register() to add themselves keyed by ChannelTypeEnum, and callers fetch a channel by type through channel(), which throws UnsupportedChannelException if that channel type was never registered [@channel-manager]. This registry is what makes channels pluggable — enabling or disabling a channel implementation is a registration-time decision, not a change to every call site that sends messages.
ChannelResolver answers a different question: given a User, which single channel should Yaya actually use to reach them right now? It checks the user's ChannelAccount records against a fixed preference order — Telegram, then WhatsApp, then SMS, then a raw phone-number SMS fallback — returning the first channel type that both has a registered implementation and one the user is actually reachable on [@channel-resolver]. The ordering is deliberate, not arbitrary: Telegram comes first because "pilot users are Telegram-only; falling back to a phone-based channel for them would require a phone they haven't given us yet" [@channel-resolver]. ChannelResolver also accepts an optional preferredChannel, so a caller that wants a specific channel (for example, replying on the channel a message arrived on) can request it and still fall back through the same preference chain if that channel turns out to be unreachable.
Why this buys channel parity¶
Because send(), sendToIdentifier(), and normalizeIdentifier() are identical method names across every implementation, a new channel — the PWA push channel, for instance — can be added by writing one class that satisfies ChannelInterface and registering it with ChannelManager, without touching the dispatch, broadcast, or alert code that already calls channel()->send(). This is what lets weather alerts, onboarding messages, and broadcast campaigns reach a Fortune Credit user in Kenya over Telegram and a Bancamía user in Colombia over WhatsApp through the exact same dispatch code path, with the FSP-level tenant credentials resolved separately inside each channel implementation.
For the broader vocabulary this concept sits inside, see Atram, Yaya, FSP, and Rada vocabulary.