Liking cljdoc? Tell your friends :D

boundary-push: Push Notification Library Design

Date: 2026-05-24 Status: Draft Library: libs/push/

Summary

Multi-platform push notification delivery library for the Boundary framework. Supports Firebase Cloud Messaging (FCM) and Apple Push Notification service (APNs) directly — no third-party abstraction services. Follows FC/IS architecture with defpush macro consistent with defreport, defevent, and defworkflow.

Design Decisions

DecisionChoiceRationale
Device token storageSelf-contained in boundary-pushSame pattern as jobs (own store). No coupling to user module. Simple table, mechanical cleanup.
Provider strategyFCM + APNs from day oneTwo providers expose bad abstractions early. Covers full mobile ecosystem.
defpush scopeThick definitionsAll config in definition (title, body, i18n, priority, TTL, deep-link, retry). Call sites stay clean. Matches other macros.
Provider protocolsPlatform-specific behind unified serviceIFCMProvider + IAPNsProvider instead of single IPushProvider. FCM and APNs have fundamentally different APIs/payloads.
Delivery analyticsFull with HMAC-secured callback endpointServer-side send tracking + client-reported delivery/open events via HMAC-signed callback.
Jobs integrationHard dependencyAll sends go through job queue. Push without retry/queue is fragile — no transport-level fallback like SMTP.
i18nBuilt-in locale mapsLocale maps in defpush definition. No dependency on boundary-i18n. Push text is short and self-contained.

Library Structure

libs/push/
├── src/boundary/push/
│   ├── core/
│   │   ├── notification.clj       # defpush macro, registry, template rendering
│   │   ├── delivery.clj           # Pure: build platform payloads, retry calc, fan-out logic
│   │   ├── device.clj             # Pure: token validation, platform detection, staleness check
│   │   └── analytics.clj          # Pure: aggregate stats, rate calculations
│   ├── ports.clj                  # IPushService, IFCMProvider, IAPNsProvider, IDeviceTokenStore, IPushAnalyticsStore
│   ├── schema.clj                 # Malli schemas for all domain types
│   └── shell/
│       ├── service.clj            # IPushService impl — orchestrates providers, fan-out, analytics
│       ├── persistence.clj        # IDeviceTokenStore + IPushAnalyticsStore impl (next.jdbc)
│       ├── adapters/
│       │   ├── mock.clj           # MockFCMProvider + MockAPNsProvider (dev/test)
│       │   ├── fcm.clj            # Google FCM v1 API (HTTP, OAuth2 service account)
│       │   └── apns.clj           # Apple APNs (HTTP/2, JWT or certificate auth)
│       ├── handlers.clj           # Ring handlers: device registration + analytics callback
│       ├── jobs.clj               # Job handlers for async delivery + scheduled pushes
│       └── module_wiring.clj      # Integrant init-key/halt-key!
├── test/boundary/push/
│   ├── core/
│   │   ├── notification_test.clj
│   │   ├── delivery_test.clj
│   │   └── device_test.clj
│   └── shell/
│       ├── persistence_test.clj   # Contract tests (H2)
│       └── service_test.clj       # Integration tests (mock adapters)
├── resources/migrations/
│   ├── 001-device-tokens.up.sql
│   ├── 001-device-tokens.down.sql
│   ├── 002-push-log.up.sql
│   ├── 002-push-log.down.sql
│   ├── 003-analytics-events.up.sql
│   └── 003-analytics-events.down.sql
├── deps.edn
├── build.clj
└── AGENTS.md

Dependencies:

  • Hard: boundary/jobs, boundary/core
  • Dev/test: boundary/devtools

Protocols (ports.clj)

IPushService — consumer-facing orchestrator

(defprotocol IPushService
  (send-push! [this notification-id data opts]
    "Enqueue push delivery for all user devices. opts: {:user-id uuid, :locale kw}")
  (schedule-push! [this notification-id data opts scheduled-at]
    "Schedule push for future delivery via jobs.")
  (broadcast! [this notification-id data opts]
    "Send to all registered devices matching opts: {:platform kw, :app-id str}"))

;; Note: send-to-device is an internal function in shell/service.clj,
;; not part of the public protocol. Job handlers call it directly.

IFCMProvider — Firebase Cloud Messaging

(defprotocol IFCMProvider
  (fcm-send! [this payload]
    "Send FCM message. Returns {:success? bool :message-id str :error map}")
  (fcm-send-multicast! [this payload tokens]
    "Send to multiple FCM tokens. Returns per-token results.")
  (fcm-validate-token [this token]
    "Dry-run send to check token validity."))

IAPNsProvider — Apple Push Notification service

(defprotocol IAPNsProvider
  (apns-send! [this payload device-token]
    "Send APNs notification. Returns {:success? bool :apns-id str :error map}")
  (apns-send-batch! [this payload device-tokens]
    "Send to multiple APNs devices. Returns per-token results."))

IDeviceTokenStore — persistence

(defprotocol IDeviceTokenStore
  (register-device! [this user-id device-info]
    "Store device token. device-info: {:token str :platform kw :app-id str}")
  (unregister-device! [this user-id device-token]
    "Remove device token.")
  (get-user-devices [this user-id]
    "All active devices for user.")
  (get-devices-by-platform [this platform opts]
    "All devices for platform. opts: {:limit n :offset n}. Used by broadcast.")
  (mark-token-invalid! [this device-token]
    "Flag token as invalid after provider rejection.")
  (cleanup-stale-tokens! [this max-age-days]
    "Purge tokens not used within max-age-days."))

IPushAnalyticsStore — delivery tracking

(defprotocol IPushAnalyticsStore
  (record-send! [this event]
    "Log send attempt with provider response.")
  (record-delivery! [this event]
    "Log client-reported delivery confirmation.")
  (record-open! [this event]
    "Log client-reported notification open.")
  (get-push-stats [this notification-id opts]
    "Aggregate stats: sent/delivered/opened/failed counts.")
  (cleanup-old-events! [this retention-days]
    "Purge analytics events older than retention-days. Recommended: 90 days."))

defpush Macro

Definition

(defpush order-shipped
  {:id           :order-shipped
   :title        {:en "Order Shipped" :nl "Bestelling Verzonden"}
   :body         {:en "Your order {{order-id}} is on its way!"
                  :nl "Je bestelling {{order-id}} is onderweg!"}
   :channels     #{:fcm :apns}
   :priority     :high
   :ttl          3600
   :deep-link    "/orders/{{order-id}}"
   :silent?      false
   :collapse-key :order-status
   :retry        {:max-attempts 3 :backoff :exponential}})

Registry

  • Global atom-based registry (same as defreport, defevent, defworkflow)
  • No validation at registration time (consistent with existing macros)
  • Separate valid-push? / explain-push functions for explicit validation
  • get-push, list-pushes, clear-registry! for lookup and test isolation

Template Rendering (pure)

  • render-template — interpolates {{var}} placeholders with data map
  • resolve-content — resolves localized content with fallback chain: requested locale -> :en -> first available
  • build-notification — combines locale resolution + template rendering into ready-to-send map

Usage

(push/send-push! push-service :order-shipped
  {:order-id "ORD-123" :eta "2 hours"}
  {:user-id user-id :locale :nl})

Schemas (schema.clj)

SchemaPurpose
PushDefinitionValidates defpush definitions via valid-push? / explain-push
DeviceInfoInput for device registration (token, platform, app-id)
DeviceRecordFull device record with metadata and active flag
SendPushInputInput for send-push! (user-id, locale)
AnalyticsEventSend/delivery/open event record
PushStatsAggregated stats output (counts + rates)
CallbackPayloadMobile app callback input (device-token, provider-message-id, event-type, callback-token)
LocalizedStringUnion type: plain string or locale->string map
RetryConfigRetry configuration (max-attempts, backoff strategy)

Database Schema

push_device_tokens

ColumnTypeNotes
idUUIDPK
user_idUUIDNOT NULL
tenant_idUUIDOptional, for multi-tenant contexts
tokenVARCHAR(512)NOT NULL
platformVARCHAR(10)'fcm' or 'apns'
app_idVARCHAR(255)NOT NULL
device_nameVARCHAR(255)Optional
os_versionVARCHAR(50)Optional
activeBOOLEANDefault TRUE, soft-deactivation
created_atTIMESTAMP
last_used_atTIMESTAMP

Unique constraint on (token, app_id). Indexes on (user_id, active) and (platform, active).

Multi-tenancy: tenant_id columns are optional across all tables. Tenant scoping is handled at middleware/context layer (same pattern as jobs module shell/tenant_context.clj), not baked into protocol method signatures.

push_send_log

ColumnTypeNotes
idUUIDPK
notification_idVARCHAR(255)defpush :id
user_idUUIDOptional
device_token_idUUIDFK to push_device_tokens.id
device_tokenVARCHAR(512)Raw token for audit (survives token cleanup)
platformVARCHAR(10)
titleVARCHAR(500)Rendered title
bodyTEXTRendered body
priorityVARCHAR(10)Default 'normal'
statusVARCHAR(20)queued/sent/failed
provider_message_idVARCHAR(255)From FCM/APNs response
error_messageTEXTOn failure
created_atTIMESTAMP
sent_atTIMESTAMP
tenant_idUUIDOptional, for multi-tenant contexts

Write-once table: status reflects send outcome only. Post-send states (delivered/opened) live in push_analytics_events.

Indexes on (notification_id, created_at) and (user_id, created_at).

push_analytics_events

ColumnTypeNotes
idUUIDPK
notification_idVARCHAR(255)
device_tokenVARCHAR(512)
platformVARCHAR(10)
event_typeVARCHAR(20)sent/delivered/opened/failed
user_idUUIDOptional
provider_message_idVARCHAR(255)
error_messageTEXT
timestampTIMESTAMP
tenant_idUUIDOptional, for multi-tenant contexts

Indexes on (notification_id, event_type) and (timestamp).

Retention policy: cleanup-old-events! purges events older than configurable retention period (recommended: 90 days). Run as scheduled job via boundary-jobs.

Delivery Flow

send-push! → enqueue :push/send job → job worker picks up
  → resolve defpush definition from registry
  → fetch user's active devices from store
  → group devices by platform
  → for each platform group:
      → build platform-specific payload (pure, in core/delivery.clj)
      → call IFCMProvider or IAPNsProvider
      → record-send! analytics event per device
      → mark-token-invalid! for rejected tokens
  • broadcast! uses paginated device fetch to avoid OOM on large device sets
  • schedule-push! is a delayed job — jobs module handles scheduling
  • Retry handled by jobs module retry config + pure retry-delay-ms calculation in core

Platform Payload Building (pure)

  • build-fcm-payload — transforms rendered notification into FCM v1 API structure (token, notification, data, android config)
  • build-apns-payload — transforms into APNs structure (aps alert, sound, badge, content-available, mutable-content)
  • Both are pure functions, fully testable without I/O

Error Classification (pure, in core/delivery.clj)

Pure function classify-error maps provider error codes to action categories:

CategoryFCM errorsAPNs errorsAction
:retryableUNAVAILABLE, INTERNALServiceUnavailableRe-enqueue job with backoff
:token-invalidUNREGISTERED, INVALID_ARGUMENTBadDeviceToken, Unregisteredmark-token-invalid!, don't retry
:rate-limitedQUOTA_EXCEEDEDTooManyRequestsRe-enqueue with longer backoff
:permanentPERMISSION_DENIED, SENDER_ID_MISMATCHBadCertificate, ForbiddenLog error, don't retry

Job handler consults classify-error before deciding to re-enqueue or give up.

Invalid Token Feedback Loop

Provider returns :token-invalid classified error → mark-token-invalid! sets active = false → future sends skip that token. cleanup-stale-tokens! purges old inactive tokens periodically.

HTTP Endpoints

MethodPathAuthPurpose
POST/api/push/devicesUserRegister device token
GET/api/push/devicesUserList user's devices
DELETE/api/push/devices/:tokenUserUnregister device
POST/api/push/callbackHMACMobile app delivery/open callback
GET/api/push/stats/:notification-idAdminDelivery/open rate stats

Callback Security (HMAC)

Callback endpoint is secured with HMAC-signed tokens:

  1. When sending a push, server generates HMAC: HMAC-SHA256(server-secret, provider-message-id)
  2. HMAC token is included in push payload's data field as callback-token
  3. Mobile app sends callback-token back with callback POST
  4. Server verifies HMAC before accepting event

This prevents fabricated delivery/open events without requiring user authentication. Duplicate callbacks are idempotent (upsert by provider-message-id + event-type). Rate limiting recommended at middleware level.

Integrant Configuration

Dev/Test (mock providers)

:boundary.push/fcm-provider  {:provider :mock}
:boundary.push/apns-provider {:provider :mock}
:boundary.push/device-store  {:db #ig/ref :boundary/datasource}
:boundary.push/analytics-store {:db #ig/ref :boundary/datasource}
:boundary.push/service
  {:device-store     #ig/ref :boundary.push/device-store
   :analytics-store  #ig/ref :boundary.push/analytics-store
   :fcm-provider     #ig/ref :boundary.push/fcm-provider
   :apns-provider    #ig/ref :boundary.push/apns-provider
   :job-queue        #ig/ref :boundary.jobs/queue
   :callback-secret  #env PUSH_CALLBACK_SECRET}
:boundary.push/job-handlers
  {:push-service    #ig/ref :boundary.push/service
   :job-registry    #ig/ref :boundary.jobs/registry}
:boundary.push/routes
  {:device-store     #ig/ref :boundary.push/device-store
   :analytics-store  #ig/ref :boundary.push/analytics-store
   :callback-secret  #env PUSH_CALLBACK_SECRET}

Production (real providers)

:boundary.push/fcm-provider
  {:provider         :fcm
   :project-id       #env FIREBASE_PROJECT_ID
   :credentials-path #env GOOGLE_APPLICATION_CREDENTIALS}

:boundary.push/apns-provider
  {:provider   :apns
   :team-id    #env APNS_TEAM_ID
   :key-id     #env APNS_KEY_ID
   :key-path   #env APNS_KEY_PATH
   :bundle-id  #env APNS_BUNDLE_ID
   :sandbox?   false}

Testing Strategy

LayerMetadataWhatHow
Unit^:unitTemplate rendering, payload building, retry calc, locale fallback, schema validationPure function tests, no mocks
Integration^:integrationFull send flow, job handler execution, Ring handlersMock providers + in-memory stores
Contract^:contractDevice CRUD, analytics queries, token lifecyclenext.jdbc against H2
clojure -M:test:db/h2 :push                              # All push tests
clojure -M:test:db/h2 :push --focus-meta :unit            # Unit only
clojure -M:test:db/h2 :push --focus-meta :contract        # Contract only
clojure -M:test:db/h2 --focus boundary.push.core.notification-test  # Single ns

Test isolation: clear-registry! in fixtures between defpush tests.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close