Automating Notifications with Sorbet EmailNotify — Best PracticesAutomated notifications are the glue that keeps modern web applications responsive, informative, and engaging. For Ruby developers using Sorbet for type checking, integrating email automation with a typed approach reduces runtime surprises and improves maintainability. This article covers best practices for implementing automated notifications using Sorbet EmailNotify: design principles, architecture patterns, typing strategies, error handling, testing, and deployment considerations.
Why use Sorbet with Email automation?
- Sorbet provides static typing for Ruby, catching many errors at development time rather than in production.
- EmailNotify (hereafter referring to a pattern/library that wraps email-sending logic) centralizes notification logic, making it easier to audit and modify notification behavior.
- Combining the two gives you typed, maintainable, and testable notification flows.
Design principles
- Single responsibility: separate message composition from delivery mechanics.
- Explicit types at boundaries: annotate inputs/outputs of notification services to make contracts clear.
- Idempotency: ensure retries don’t cause duplicate side effects.
- Observability: log events, metrics, and errors with context to facilitate debugging.
- Security: avoid leaking PII in logs, and use signed URLs/tokens where appropriate.
Recommended architecture
- Notification composer: builds email content (subject, body, templates, metadata).
- Delivery adapter(s): sends emails via SMTP, third-party APIs (SendGrid, SES), or local queue for development. Each adapter implements a typed interface.
- Queueing layer: enqueues delivery jobs (Sidekiq/ActiveJob/Que) to decouple user requests from slow network I/O.
- Retry policy: exponential backoff with a max attempts cap; track attempts in job metadata.
- Audit/log store: persistent record of sent notifications for compliance and debugging.
Example flow:
- Application triggers NotificationService.notify(user, :signup_welcome, data).
- Composer returns EmailMessage typed object.
- NotificationService enqueues delivery job with EmailMessage.
- Worker executes delivery using configured adapter.
- Delivery adapter returns DeliveryResult typed object; worker logs and updates audit store.
Typing strategies with Sorbet
Use Sorbet to define clear contracts for each layer:
-
Define value objects for email data: “`ruby
typed: true
class EmailAddress extend T::Sig sig { returns(String) } attr_reader :address
sig { params(address: String).void } def initialize(address)
@address = address
end end
class EmailMessage extend T::Sig sig { returns(EmailAddress) } attr_reader :to sig { returns(String) } attr_reader :subject sig { returns(String) } attr_reader :body
sig { params(to: EmailAddress, subject: String, body: String).void } def initialize(to:, subject:, body:)
@to = to @subject = subject @body = body
end end
- Define interface for delivery adapters: ```ruby # typed: true module DeliveryAdapter extend T::Sig sig { abstract.params(message: EmailMessage).returns(DeliveryResult) } def deliver(message); end end
-
Use enums or symbols for notification types:
# typed: true class NotificationType < T::Enum enums do Welcome = new PasswordReset = new Invoice = new end end
Annotate job payloads and worker interfaces to avoid runtime surprises when enqueuing or processing jobs.
Template management
-
Prefer structured templates (ERB/Haml with strict variables) and validate at compile time where possible.
-
Use typed template contexts: “`ruby
typed: true
class WelcomeTemplateContext extend T::Sig sig { returns(String) } attr_reader :first_name
sig { params(first_name: String).void } def initialize(first_name:)
@first_name = first_name
end end
- Keep templates small and focused; complex logic belongs in composer classes. - Localize strings using i18n and validate presence of translations during CI. --- ## Queueing and retry best practices - Use background jobs for slow operations. Annotate job args with Sorbet types. - Idempotency: include a deduplication key in job arguments (e.g., user_id + notification_type + event_id). - Retry policy: exponential backoff, stop retries for 4xx errors from providers (bad request / unauthorized), retry on transient 5xx/network errors. - Poison queue handling: move repeatedly failing jobs to a dead-letter queue and notify on-call via separate channel. --- ## Error handling and observability - Return rich typed results from delivery adapters (success, transient_error, permanent_error) with metadata: ```ruby # typed: true class DeliveryResult extend T::Sig sig { returns(Symbol) } attr_reader :status sig { returns(T::Hash[String, T.untyped]) } attr_reader :meta sig { params(status: Symbol, meta: T::Hash[String, T.untyped]).void } def initialize(status:, meta: {}) @status = status @meta = meta end end
- Log minimal, non-PII context: notification type, user id (hashed), adapter, status, error code.
- Emit metrics: queue time, delivery latency, success rate, error types.
- On permanent failures, notify developers or route to manual intervention workflow.
Security & privacy
- Never log full email bodies or PII. Hash identifiers when logging.
- Use signed expiring tokens for any action links in emails.
- Rate-limit outbound emails to avoid accidental mass sends.
- Validate templates to avoid injection (escape user content).
Testing strategies
- Unit tests: composer logic, template rendering, and adapter behavior using mock responses.
- Integration tests: run delivery adapter against a sandbox environment (e.g., SendGrid test API) or use SMTP capture tools (MailCatcher).
- End-to-end tests: simulate queueing and worker execution with test doubles for external APIs.
- Contract tests: ensure adapters conform to the DeliveryAdapter interface.
- Typing tests: run Sorbet static checks as part of CI; fail builds on any type errors.
Deployment & configuration
- Use feature flags for rolling out notification types.
- Keep adapter credentials in encrypted secrets; rotate keys regularly.
- Provide a “dry-run” mode that renders templates and simulates delivery without sending.
- Canary releases: send to a small subset of users, monitor metrics, then scale.
Common pitfalls and how to avoid them
- Sending duplicate emails: use dedup keys and idempotent delivery APIs.
- Template breakage in production: validate templates in CI and include template rendering smoke tests.
- Silent failures: ensure workers report errors and that alarm thresholds exist for delivery failure rates.
- Leaking sensitive data: sanitize logs and audit content.
Example: simple typed NotificationService
# typed: true class NotificationService extend T::Sig sig { params(adapter: DeliveryAdapter).void } def initialize(adapter) @adapter = adapter end sig { params(user: User, type: NotificationType, data: T::Hash[Symbol, T.untyped]).returns(DeliveryResult) } def notify(user:, type:, data: {}) message = compose(user: user, type: type, data: data) @adapter.deliver(message) end private sig { params(user: User, type: NotificationType, data: T::Hash[Symbol, T.untyped]).returns(EmailMessage) } def compose(user:, type:, data:) case type when NotificationType::Welcome EmailMessage.new( to: EmailAddress.new(user.email), subject: "Welcome, #{user.first_name}", body: ApplicationController.render(template: 'mailers/welcome', locals: { user: user }) ) else raise "Unhandled notification type" end end end
Conclusion
Using Sorbet with a clear EmailNotify pattern yields a robust notification system: types make contracts explicit, adapters decouple delivery, queues add resilience, and observability prevents silent failures. Follow the principles above—single responsibility, explicit typing, idempotency, security, and monitoring—to build a reliable, maintainable notification layer that scales with your application.
Leave a Reply