Notification Interface
The contract every notification type implements.
A notification is a struct that carries the data for a single message and implements the Notification interface.
Interface
type Notification interface {
Via(notifiable Notifiable) []string
}Just one required method. Everything else — ToMail, ToWhatsApp, ToSMS, etc. — is optional and discovered by each channel via Go type assertion at send time.
Via
Returns the channel names this notification should go through for the given recipient. It receives the Notifiable so routing can depend on the recipient:
func (n OrderShipped) Via(notifiable notification.Notifiable) []string {
user := notifiable.(User)
channels := []string{"database"} // in-app is always on
if user.EmailVerified { channels = append(channels, "mail") }
if user.WantsWhatsApp { channels = append(channels, "whatsapp") }
return channels
}Per-channel render methods
Each channel expects one method that produces its message shape. The full signatures are in Message Types. The surface:
func (n MyNotification) ToMail(u Notifiable) *mail.Message
func (n MyNotification) ToWhatsApp(u Notifiable) *whatsapp.Message
func (n MyNotification) ToSMS(u Notifiable) *sms.Message
func (n MyNotification) ToPush(u Notifiable) *push.Message
func (n MyNotification) ToChat(u Notifiable) *chat.Message
func (n MyNotification) ToDatabase(u Notifiable) *database.Message
func (n MyNotification) ToWebhook(u Notifiable) *webhook.MessageThere is no ToSlack/ToTelegram/ToDiscord/ToTeams. Slack, Telegram,
Discord, and Microsoft Teams all share a single chat channel: implement
ToChat returning a *chat.Message, and set provider-specific extras
(Slack attachments, Discord embeds, Telegram parse mode) on that one message.
Also note the SMS method is ToSMS (uppercase), not ToSms — a misspelling
is silently not discovered.
You only implement the ones listed in your Via(). If Via returns []string{"mail", "database"}, you need ToMail and ToDatabase — the rest don't need to exist. If a channel in Via has no matching To<Channel> method, that channel is skipped (ErrNoFormatter, logged at debug level).
Custom channels: ChannelFormatter
For channels beyond the built-in ones, implement the optional ChannelFormatter interface on the notification. The notifier calls it only when the channel doesn't implement Formatter:
type ChannelFormatter interface {
Format(channel string, notifiable Notifiable) any
}Not the same as Formatter
Don't confuse this with Formatter
on the channel side. Formatter (Format(n, notifiable) (any, error))
lives on the channel and is how every built-in driver works.
ChannelFormatter lives on the notification and is a fallback used
only when the channel doesn't implement its own Formatter.
Channel name matching
The Via() strings must match the names your registered channels report from Name(). An unregistered name surfaces through OnError:
// Registered under its own Name() — "mail" for a default mail driver.
notifier.RegisterChannel(mailgun.New(mailgun.Config{ /* ... */ }))
// "email" isn't registered → dispatch fails with ErrNoChannel.
func (n OrderShipped) Via(u notification.Notifiable) []string {
return []string{"email"}
}Notifications carry their data
Notifications are typically struct values with all data passed in at construction:
type OrderShipped struct {
OrderID string
TrackingURL string
EstimatedArrival time.Time
}
notifier.Send(ctx, user, OrderShipped{
OrderID: "A-1024",
TrackingURL: "https://...",
EstimatedArrival: time.Now().Add(48 * time.Hour),
})Testing a notification
Via and the To<Channel> methods are plain functions — test them without a notifier. Message fields like Subject are struct fields (not methods):
func TestOrderShippedVia(t *testing.T) {
user := User{EmailVerified: true, WantsWhatsApp: false}
got := OrderShipped{}.Via(user)
require.Equal(t, []string{"database", "mail"}, got)
}
func TestOrderShippedToMail(t *testing.T) {
msg := OrderShipped{OrderID: "A-1"}.ToMail(User{})
require.Contains(t, msg.Subject, "A-1")
}