Skip to content
Packages Examples Agents Blog Get started
PackageRequiredPurpose
orideconYesCore framework
oridecon-contractsYesProtocol definitions
sendgridRecommendedEmail backend
twilioRecommendedSMS backend
firebase-adminOptionalFCM push backend

Your application needs to send notifications across multiple channels — email receipts, SMS alerts, push notifications to mobile devices, and in-app inbox messages. Each channel has different providers (Twilio, SendGrid, FCM, APNs), different protocols, and different configuration. Wiring them all manually leads to tightly coupled code and makes switching providers risky.

oridecon-notification solves this by defining channel protocols (SMSChannelProtocol, PushChannelProtocol, MailerProtocol) in oridecon-contracts, providing driver implementations for each major provider, and registering them in the DI container under Named bindings for multi-backend support.


┌──────────────────────────────┐
│ NotificationModule │
│ (SMS + Push) │
│ exports: │
│ SMSChannelProtocol │
│ PushChannelProtocol │
└───────────┬──────────────────┘
┌─────────────────────────────┼──────────────────────────┐
│ │ │
┌────▼─────┐ ┌───────▼────────┐ ┌───────▼────────┐
│ TwilioSMS │ │ FCMPush │ │ APNsPush │
│ (sms) │ │ WebPushChannel │ │ (ios) │
└──────────┘ └────────────────┘ └────────────────┘
┌─────────────────┴──────────────────┐
│ MailerProvider (entry-point) │
│ exports: │
│ MailerProtocol │
└─────────────────┬──────────────────┘
┌───────────▼────────────┐
│ SendGridMailer │
│ SMTPMailer │
└────────────────────────┘

Three independent subsystems, all in one package:

SubsystemProviderProtocolsConfig Section
SMSNotificationProviderSMSChannelProtocolnotification.sms_backends
PushNotificationProviderPushChannelProtocolnotification.push_backends
EmailMailerProvider (auto-discovered)MailerProtocolmailer.backends
InboxStandalone servicesInboxStoreProtocolinbox.*

Every notification channel is abstracted behind a protocol from oridecon-contracts:

ProtocolImportsend() Signature
SMSChannelProtocoloridecon.contracts.notification.protocolssend(SMSMessage) -> Result[MessageDeliveryReceipt, NotificationError]
PushChannelProtocoloridecon.contracts.notification.protocolssend(PushMessage) -> Result[...] + send_batch(list[PushMessage])
MailerProtocoloridecon.contracts.mailer.protocolssend(EmailMessage) -> Result[MessageDeliveryReceipt, MailerError]

All send() methods return Result — expected failures (invalid number, quota exceeded) are Err, infrastructure errors (network timeout, auth failure) raise exceptions.

TypeFields
SMSMessageto: list[str], body: str, from_number, metadata
PushMessageto: list[str], title, body, data, badge, sound, image, ttl
InboxMessageid, user_id, title, body, read, created_at, metadata

Each channel supports multiple named backends. For example, two SMS providers:

notification:
sms_backends:
- name: primary
primary: true
driver: twilio
twilio:
account_sid: AC...
from_number: +15551111111
- name: fallback
driver: twilio
twilio:
account_sid: AC...
from_number: +15552222222

The primary backend gets an unnamed SMSChannelProtocol binding. All backends get Annotated[SMSChannelProtocol, Named(name)] for named injection:

from typing import Annotated
from oridecon.contracts.notification.protocols import SMSChannelProtocol
from oridecon.di.markers import Named
class AlertService:
def __init__(
self,
default_sms: SMSChannelProtocol, # primary
fallback_sms: Annotated[SMSChannelProtocol, Named("fallback")], # named
) -> None:
...

from oridecon.notification.module import NotificationModule
from oridecon.notification.config import NotificationConfig
from oridecon import Application
config = NotificationConfig(
sms_backends=[...],
push_backends=[...],
)
async with Application.boot(
name="my-app",
modules=[NotificationModule.configure(config)],
) as app:
...

The MailerProvider is auto-discovered via the oridecon.providers entry point. Configure it under mailer: in YAML:

application.yaml
mailer:
backends:
- name: transactional
primary: true
driver: sendgrid
from_email: orders@example.com
sendgrid:
api_key: "${SENDGRID_API_KEY}"

Or configure programmatically and add the provider manually:

from oridecon.notification.di.mailer_provider import MailerProvider
from oridecon.notification.config import MailerConfig, NamedMailerConfig
provider = MailerProvider(config=MailerConfig(backends=[...]))
app.add_provider(provider)
from oridecon.contracts.mailer.types import EmailMessage
from oridecon.contracts.mailer.protocols import MailerProtocol
mailer = await container.resolve(MailerProtocol)
result = await mailer.send(
EmailMessage(
to=["user@example.com"],
subject="Your Order Confirmation",
body=f"Order {order_id} confirmed.",
)
)
if result.is_err():
logger.error("email_failed", error=result.unwrap_err())

The inbox subsystem stores messages for in-app notification centers:

from oridecon.notification.inbox.service import InboxService
from oridecon.notification.inbox.memory import InMemoryInboxStore
from oridecon.contracts.notification.inbox import InboxMessage
store = InMemoryInboxStore()
svc = InboxService(store)
msg = InboxMessage.create(user_id="user-42", title="Welcome!", body="Thanks for joining")
await svc.send(msg)
messages = await svc.get_inbox("user-42")
unread = await svc.get_unread_count("user-42")

Production deployments use DatabaseInboxStore backed by SQL.


  • ✅ Use Result.match() or result.is_ok() to handle delivery outcomes — don’t unwrap blindly
  • ✅ Pin each backend with a descriptive name for Named() injection
  • ✅ Mark one backend per channel as primary: true for the unnamed default binding
  • ✅ Use NotificationModule.stub() in tests — it creates an empty config that drops all messages
  • ❌ Don’t store credentials in YAML — use SecretStr env-var overrides (ORI_NOTIFICATION__*)
  • ❌ Don’t mix SMS and push credentials in the same backend config; use separate entries

  • Architecture — provider lifecycle, contracts, extension points
  • How-Tos — SMTP email, FCM push, named backends, inbox
  • Configuration — all config keys with defaults and env vars