Skip to content
GitHub

Guide

PackageRequiredPurpose
lexigramYesCore framework
lexigram-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.

lexigram-notification solves this by defining channel protocols (SMSChannelProtocol, PushChannelProtocol, MailerProtocol) in lexigram-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 lexigram-contracts:

ProtocolImportsend() Signature
SMSChannelProtocollexigram.contracts.notification.protocolssend(SMSMessage) -> Result[MessageDeliveryReceipt, NotificationError]
PushChannelProtocollexigram.contracts.notification.protocolssend(PushMessage) -> Result[...] + send_batch(list[PushMessage])
MailerProtocollexigram.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 lexigram.contracts.notification.protocols import SMSChannelProtocol
from lexigram.di import Named
class AlertService:
def __init__(
self,
default_sms: SMSChannelProtocol, # primary
fallback_sms: Annotated[SMSChannelProtocol, Named("fallback")], # named
) -> None:
...

from lexigram.notification.module import NotificationModule
from lexigram.notification.config import NotificationConfig
from lexigram 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 lexigram.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 lexigram.notification.di.mailer_provider import MailerProvider
from lexigram.notification.config import MailerConfig, NamedMailerConfig
provider = MailerProvider(config=MailerConfig(backends=[...]))
app.add_provider(provider)
from lexigram.contracts.mailer.types import EmailMessage
from lexigram.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 lexigram.notification.inbox.service import InboxService
from lexigram.notification.inbox.memory import InMemoryInboxStore
from lexigram.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 (LEX_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