Channel Interface
The contract every driver implements — and what you'd implement for a custom channel.
A Channel is a driver. It receives an already-formatted message and delivers it. Every built-in driver (Mailgun, WAHA, Slack, etc.) implements this interface.
Interface
type Channel interface {
Name() string
Send(ctx context.Context, notifiable Notifiable, message any) error
}Name()— the channel identifier. This is the string used inNotification.Viaand the key under which the channel is registered. Built-in drivers default to their channel family ("mail","sms","chat", …) and can be overridden.Send(ctx, notifiable, message)— deliver the message. The concrete type ofmessageis channel-specific (e.g.*mail.Message,*whatsapp.Message). The driver type-asserts it and returnsErrInvalidMessageon mismatch.
Send does not receive the Notification. By the time Send runs, the
notification has already been turned into a concrete message by a
Formatter (see below). The driver only deals with the formatted payload.
How a Notification becomes a message: Formatter
The notifier converts a Notification into a channel-specific message via the optional Formatter interface:
type Formatter interface {
Format(n Notification, notifiable Notifiable) (any, error)
}A channel may implement Formatter directly. The built-in channels do this through an embedded Base (e.g. mail.Base, chat.Base) that calls the notification's ToMail/ToChat/… method and returns ErrNoFormatter when the notification doesn't support that channel. If the channel doesn't implement Formatter, the notifier falls back to the notification's ChannelFormatter.
Formatter vs ChannelFormatter
The two names look almost identical but live on opposite sides of the dispatch boundary:
Formatter(this page) — implemented by the channel. Built-in drivers get it for free via embeddedBase. Always the preferred path.ChannelFormatter(notification interface) — implemented by the notification, with signatureFormat(channel string, notifiable Notifiable) any. The notifier only consults it when the channel does not implementFormatter.
Rule of thumb: define a real driver → implement Formatter. Add support
for a custom channel without writing a driver → implement
ChannelFormatter on the notification.
Minimal custom channel
The simplest path is channel.Func, which wraps a send function — no struct required:
notifier.RegisterChannel(channel.Func("log", func(ctx context.Context, n notification.Notifiable, msg any) error {
log.Printf("[notify] to=%s msg=%v", n.GetID(), msg)
return nil
}))For a real driver, define a struct that implements Name and Send, and (usually) Formatter so the notifier knows how to build your message type:
type LogChannel struct{}
func (LogChannel) Name() string { return "log" }
func (LogChannel) Format(n notification.Notification, to notification.Notifiable) (any, error) {
f, ok := n.(interface{ ToLog(notification.Notifiable) string })
if !ok {
return nil, notification.ErrNoFormatter
}
return f.ToLog(to), nil
}
func (LogChannel) Send(ctx context.Context, to notification.Notifiable, message any) error {
text, ok := message.(string)
if !ok {
return notification.ErrInvalidMessage
}
log.Printf("[notify] %s", text)
return nil
}
notifier.RegisterChannel(LogChannel{})Receiving channel-specific config
The convention is a New(cfg) constructor returning a concrete struct that implements Channel:
package myservice
type Config struct {
APIKey string
BaseURL string
Timeout time.Duration
}
type Channel struct {
cfg Config
http *http.Client
}
func New(cfg Config) *Channel {
if cfg.Timeout == 0 { cfg.Timeout = 30 * time.Second }
if cfg.BaseURL == "" { cfg.BaseURL = "https://api.myservice.com" }
return &Channel{cfg: cfg, http: &http.Client{Timeout: cfg.Timeout}}
}
func (c *Channel) Name() string { return "myservice" }
func (c *Channel) Send(ctx context.Context, n notification.Notifiable, message any) error { /* ... */ }Retry-aware errors
The notifier wraps Send in retry-with-backoff automatically (Config.MaxRetries/RetryDelay). Retries are on by default. To mark a failure as non-retryable (bad credentials, malformed payload, 4xx), wrap it with middleware.ErrPermanent:
import "github.com/gopackx/go-notification/middleware"
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
return fmt.Errorf("myservice: rejected (%d): %w", resp.StatusCode, middleware.ErrPermanent)
}
return fmt.Errorf("myservice: upstream %d", resp.StatusCode) // retryableBackoff is RetryDelay * 2^(attempt), capped at 60s, with up to 25% jitter.
Testing a channel
A channel test typically mocks the HTTP layer with httptest.NewServer:
func TestMyChannelSend(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer srv.Close()
ch := myservice.New(myservice.Config{APIKey: "test", BaseURL: srv.URL})
err := ch.Send(ctx, testUser{}, &myMessage{ /* ... */ })
require.NoError(t, err)
}Full walkthrough
See Building a Custom Channel for a complete example, from interface to HTTP to error handling to retry semantics.