Last updated 2026-05-28

Architecture

notify is one Go package (github.com/elloloop/notify) that depends on nothing concrete, plus pluggable sub-packages for each backend. The container (cmd/notifyd) is a thin wiring layer; the library mode is the same logic with the wiring deferred to the caller.

The orchestrator

notify.Notifier is the orchestrator. It owns a Store and a ProviderRegistry and exposes a single method:

type Notifier struct {
store Store
providers *ProviderRegistry
now Clock
}
func (n *Notifier) Notify(ctx context.Context, req NotifyRequest) (NotifyResult, error)

For each (user, channel) the orchestrator:

  1. Builds a Notification row stamped with the request fields.
  2. Calls Store.CreateNotification — idempotent on (TenantID, UserID, NotificationID). A repeat call returns the existing row.
  3. Looks up the provider for channel in the registry. If none, increments Pending and continues.
  4. Resolves the destination address from req.Addresses (or, for in-app, the user id itself). Missing address ⇒ Pending.
  5. Calls Provider.Send. On error, increments Failed and records StatusFailed via UpdateStatus. On success, records the receipt's status (default StatusDelivered).

Storage failures abort the whole call. Per-channel delivery failures do not — the fan-out completes and the (delivered, pending, failed) counters surface what happened.

The dependency shape

┌───────────────────────────────────────┐
│ notify (root package) │
│ model.go · store.go · channel.go │
│ notifier.go · config.go │
│ │
│ Defines: Store, Provider, │
│ Notifier, ChannelKind, │
│ DeliveryStatus, … │
└─────────────┬─────────────────────────┘
│ injected at construction
┌──────────────────────┴──────────────────────┐
│ │
┌──┴───────┐ ┌────────────────┐ ┌──────────────┴─────────┐
│ Store │ │ ProviderReg. │ │ realtime engine │
│ (driver) │ │ (one per chan) │ │ (in-app only) │
└──────────┘ └────────────────┘ └────────────────────────┘
memory email/... Registry[T]
postgres twilio/sms RetryTracker
entdb twilio/whatsapp Conn[T]
webpush
fcm
(in-app provider)

The root package has no concrete dependencies — it imports only standard library and connect-go (for typed errors). That is what makes it library-mode-friendly: a downstream service can embed Notifier with its own Store and never pull in Twilio or FCM or pgx.

The standalone container

cmd/notifyd is the production entry point. It does three things and only three:

  1. Loads server.Config from environment variables.
  2. Builds the concrete store / providers per configuration.
  3. Hands them to server.New and calls Run.

The internal/server package owns the Connect handlers, auth middleware, lifecycle plumbing, and the in-app provider that bridges the realtime engine. It depends on the root package (interfaces), the generated proto stubs (wire types), and the realtime + store / channel sub-packages — exactly the surface a production deployment wires.

Process layout

+---------------------------- notifyd container ----------------------------+
| |
| :8081 NotificationInternalService ─── X-Notify-Internal-Token check |
| │ |
| ▼ |
| :8080 NotificationClientService ─── Authorization: Bearer <JWT> |
| · StreamEvents (SSE / Connect server-stream) |
| · GetNotifications, AckNotification, AckDataChange |
| · RegisterPushToken |
| │ |
| ┌───────────┴────────────────────────────────┐ |
| ▼ ▼ |
| notify.Notifier ◄────── realtime.Registry / RetryTracker |
| │ (only when LiveConnections.Enabled) |
| ▼ |
| notify.Store |
| │ |
| ▼ |
| memory · postgres · entdb |
| |
| :9090 /healthz · /metrics (Prometheus exposition) |
+---------------------------------------------------------------------------+

Why two services, not one

The producer surface and the recipient surface have orthogonal auth stories: producers are trusted backend code, recipients are untrusted end users. Splitting them into two Connect services means:

  • The internal token never touches a browser; the user JWT never reaches a producer.
  • Operators can publish the recipient port behind a CDN/WAF and keep the producer port reachable only from the cluster network — at the listener level, not via middleware that could regress.
  • The schemas evolve independently. Adding a producer-side field never expands the recipient API by accident.

Why notification IDs are caller-supplied

Why this design. The platform is idempotent on (tenant_id, user_id, notification_id) because the producer knows when a logical event is "the same one" and the platform does not. Examples: a webhook retry, a Kafka redelivery, an at-least-once event fanout in the producer. If notify generated the id, the producer would either have to send "same logical event" twice (duplicate delivery) or maintain its own dedupe table that mirrors notify's. We push the decision to the source of truth — the producer.

Related