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:
- Builds a
Notificationrow stamped with the request fields. - Calls
Store.CreateNotification— idempotent on(TenantID, UserID, NotificationID). A repeat call returns the existing row. - Looks up the provider for
channelin the registry. If none, incrementsPendingand continues. - Resolves the destination address from
req.Addresses(or, for in-app, the user id itself). Missing address ⇒Pending. - Calls
Provider.Send. On error, incrementsFailedand recordsStatusFailedviaUpdateStatus. On success, records the receipt's status (defaultStatusDelivered).
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:
- Loads
server.Configfrom environment variables. - Builds the concrete store / providers per configuration.
- Hands them to
server.Newand callsRun.
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
- Channels & Providers — the channel ↔ provider taxonomy in detail
- Store & Conformance — the driver-agnostic spec
- Realtime Engine — Registry, RetryTracker, Conn
- Auth Model — JWT + internal token