D3 Design Notification Service - AY2627S1-CS3219-P23/foc GitHub Wiki

Technical Solution — D3 Design — Notification Service

The D3 brief has no part of its own for the Notification Service — it appears only as the consumer in Part 3's asynchronous workflow. It is a full fifth service all the same, so this page gives it the same treatment that Parts 1 and 2 of the D3 design page give Order and Credit: the same questions, in the same section layout. The design of record is notification-service.md (decisions D1–D32); the service record is the notification README.

Status: complete. The consumer pipeline (dedupe, retry/DLQ, STOMP push) merged before D3 started. The rest landed over the deliverable: the web STOMP client #185 (D23), the REST API #205 (D24–D28), the Notification Center and bell badge #199, the dedupe-table purge #201, read-time display names #222 (D32), request.dropped ingest #214, and the bell chime #215. All merged by 2026-10-20.

flowchart LR
    ORD["Order Service"]
    MQ[/"request-events<br/>topic exchange"/]
    WQ[/"work queue<br/>notification-service.request-events"/]
    RQ[/".retry (TTL 10 s)"/]
    DLQ[/".dlq (durable)"/]
    NS["Notification Service<br/>listener → idempotent processor Ā·<br/>REST API Ā· STOMP gateway Ā·<br/>retention purge scheduler"]
    NDB[("notification-db<br/>PostgreSQL 17")]
    GW["API gateway"]
    WEB["Web SPA<br/>bell badge Ā· Notification Center"]
    ORD ==>|"12 cataloged events,<br/>routing key = eventType"| MQ
    MQ ==>|"request.#"| WQ
    WQ ==> NS
    NS ==>|"failure, attempts left"| RQ
    RQ ==>|"TTL expiry"| WQ
    WQ ==>|"exhausted / unconvertible"| DLQ
    NS --- NDB
    NS ==>|"STOMP /user/queue/notifications ≤5 s"| WEB
    WEB -->|"REST list Ā· unread count Ā·<br/>mark read (via /api/notification)"| GW
    GW --> NS
Loading

ā„¹ļø Same conventions as the main D3 page: (chosen: …) cites the decision record. The decision IDs here are notification-service.md's D-numbers. Gaps in the numbering (D5, D14, D29–D31) are superseded decisions, removed by convention.


§1 Database choice & schema — Notif F2.1 Ā· F3

(chosen: PostgreSQL + Spring Data JPA — D3.) The requirement that decided this is the same one that decided Credit's engine: dedupe and the notification insert must commit atomically (F2.1). The processed-event ID and the notification rows live in one schema, so a single ACID transaction covers both "record that I saw event X" and "create its notifications". A redelivered event can therefore never half-apply. Beyond that, the reads are simple indexed lookups (one recipient's page, an unread count), and every other service already runs Postgres, so compose carries no new engine.

Schema (JPA-derived, ddl-auto: update — see the main page §P4.4):

erDiagram
    notifications {
        bigint id PK "req"
        text recipient_id "req - platform user ID (JWT sub), indexed"
        text event_id "req - the event that produced this row"
        text event_type "req - e.g. request.accepted"
        text payload "req - the event body as stored (IDs only, D32)"
        timestamptz occurred_at "req - from the event"
        boolean is_read "req - default false (F3.2)"
        timestamptz created_at "req - list order, newest first (D25)"
    }
    processed_events {
        text event_id PK "dedupe inbox (D4)"
        timestamptz processed_at "req - indexed for the purge (issue #137)"
    }
Loading

One event fans out to one notifications row per entry in its parties[] (F1.2). For example, request.accepted notifies the requester and the courier: two rows, same event_id. The unique key on processed_events is what makes a redelivery a no-op (§5). Durable state lives only in this database — the broker holds in-flight messages, not service state — so database-per-service is untouched.

Retention (D9, issue #137): a scheduled purge runs daily at 03:00 and deletes notifications and processed_events rows older than NOTIF_RETENTION_DAYS (default 30, F3.4). Purging the dedupe inbox reopens the dedupe window for those event IDs. The team accepted this (#201): hitting it would need a delivery to resurface more than 30 days later (a manual DLQ replay, or an unacked delivery across an outage that long), and even then it would only recreate rows the purge had already deleted.

§2 The pipeline — the service's "state machine" — Notif F1 Ā· F2

Where Order has a request lifecycle, this service has one idempotent processing path per delivery. The interesting transitions belong to the message:

Stage What happens On failure
Publish (producer side) Order's outbox poller publishes to the request-events topic exchange (D16), routing key = the canonical eventType (D18); durable exchange/queues, persistent messages (D8) fire-and-forget for the producer: delivery trouble never affects the producing operation (F1.4)
Consume the work queue (bound request.#) delivers to the listener; manual ack only after the DB transaction commits (NFR1.1) broker redelivers — at-least-once
Dedupe + store one transaction: insert event_id into processed_events + one notification row per party (D4, F2.1–F1.2) unique-key hit = already seen → ack, no second notification
Push after commit (AFTER_COMMIT), each row goes out over STOMP to /user/queue/notifications — inside the 5 s budget (Order NFR1.1–1.2) push is best-effort: an offline user catches up via the REST list (F3.1)
Retry (D6) a processing failure republishes to the TTL retry queue — 10 s, 3 total attempts (NOTIFICATION_RETRY_*), attempts counted in a listener-stamped header (RabbitMQ 4 resets x-death on client republish) attempts left → back to the work queue on TTL expiry
Dead-letter (D7/D20/D21) exhausted retries → durable DLQ for inspection/replay; an unconvertible message (unknown eventType, missing required field) dead-letters intact on first rejection — retrying what cannot convert could never succeed nothing is dropped; replay after a consumer upgrade is the F1.3 path for unknown types

Twelve event types are cataloged in foc-contracts (D15/D17/D18: flat typed records, no envelope, identity in the body, one JSON fixture each): the lifecycle events request.created/accepted/collected/courier-arrived/completed/cancelled/dropped/expired, the saga's request.submitted and request.rejected, and the credit domain's two reply events. The credit replies travel on the separate credit-events exchange, which this service does not bind. Adding a new event type is a contracts release plus a consumer jar bump — the request.# binding and the publisher never change (F1.3, D16).

§3 API design — query patterns — Notif F3 Ā· F1.2

Two client-facing surfaces, both behind the gateway at /api/notification/** (the prefix is stripped in transit).

REST (D24–D28, #205). The base path is /notifications/me: the caller is always the JWT sub, and no route takes a user ID (the same convention as credit-service's /credits/me). There is simply no path that could reach another user's rows:

Route What it does
GET /notifications/me?page=0&size=20[&unread=true] One page, ordered by created_at newest-first with an ID tiebreak (D25). That order puts a late-delivered event at the top, where a catch-up reader expects it; there is no sort parameter. page and size are hand-validated (D27: ≄ 0 / 1–100, out of range → 400); unread is deliberately as lenient as Spring's boolean binding. The response is the platform page envelope, and each element is the same NotificationDto the STOMP push sends — one frontend type covers both
GET /notifications/me/unread-count {"count": n} — the number the bell badge shows. The SPA re-syncs it on load, on reconnect, and after writes
PATCH /notifications/me/{id} body {"read": true|false} Flips one row, in either direction → 204. Repeats return the same 204 (D26: one ownership-scoped bulk UPDATE, row count checked)
POST /notifications/me/read-all Marks every unread row read in one statement → 204, also when nothing was unread

Errors are RFC 9457 problem+json. A page past the last one is an empty page. A mark-read racing the retention purge can 404 on a row the client just displayed — which is correct: the row is gone.

STOMP push (D2; session mechanics decided under issue #67, built in #185). The endpoint is /ws through the gateway, with SockJS fallback kept server-side (the SPA itself uses native WebSocket only). Clients subscribe to /user/queue/notifications; heartbeats are 10 s each way. The SPA runs one client for the whole app, owned by a NotificationProvider layout route. It reconnects with 1 s→30 s exponential backoff. On a server ERROR frame — in practice a dead token — it retries three times, 30 s apart, then stops and says so: the bell greys out and shows "updates paused, sign in again"; recovery is a re-login. This socket is the system's only standing connection. Everything else between the frontend and the services is stateless REST, so exactly one service ever pays for connection state (heartbeats, reconnects, fan-out).

Display names (D32, #222). Events and stored payloads carry party IDs only. The web resolves courierId → username at read time, through the gateway (GET /api/user/users/{id}), using the caller's own JWT — no service-to-service call and no internal credential. (This superseded D30/D31's ingest-time lookup and internal token.) The SPA caches lookups for 10 minutes, 30 s for failures, with in-flight dedupe. Any lookup failure falls back to actor-less copy, so a notification row is never blocked on a name. A side benefit: the copy always shows the current username, which D30's ingest-time snapshot could not.

§4 Authorization — identity and relationship — User F6.1

  • REST. By the time a request arrives, the gateway has already checked the signature, the expiry and the denylist. The service's own JWT filter authenticates the subject and sets empty authorities (D28) — no route is role-gated. The relationship check is structural: every query and write is scoped WHERE recipient_id = :sub. D26 doubles as the anti-oracle: a foreign row and a missing row both answer 404, the same masking order-service uses on its steps.
  • STOMP. The CONNECT frame carries the bearer token. Frame-level traffic never passes through the gateway's access phase, so this service does the denylist lookups itself at CONNECT (D23 — the one deliberate exception to gateway decision G4). It checks the token's own denylist:<jti> key (G10) and the account's denylist:user:<id> reset/demote cutoff (G11/G12), failing open behind 2 s timeouts, as G6 prescribes. The interceptor also polices frames (added in the #185 review): every client SEND is refused — an unprefixed SEND to /user/{id}/queue/notifications would have spoofed another user's badge — and SUBSCRIBE is allowed only to the caller's own destination.
  • Accepted limit (recorded in notification-service.md): revocation is checked at CONNECT only. An already-open socket outlives a logout elsewhere, a password reset, and even the token's exp, until it closes. The SPA closes its own socket on logout, so the real exposure is a second session holding the same token — bounded by that session's lifetime.

§5 Concurrency, duplicates & corner cases — Notif F2.1 Ā· F2.2

  • The same event delivered twice (broker redelivery, outbox re-publish, manual DLQ replay). The processed_events insert and the notification insert share one transaction (D4). The second delivery hits the unique key and is acked without producing a second notification. This is this service's version of what Order does with @Version and Credit does with reservation status: each consumer owns its own idempotency.
  • Two deliveries processed concurrently. Both race the same unique key. Exactly one transaction commits; the loser's constraint violation is treated as already-seen.
  • Out-of-order arrival (say collected before accepted, after a retry). Tolerated by design: each event is a self-contained fact, and the Order DB — not the event stream — is the truth the UI reads on click. The team removed per-entity sequencing (D19; D1's F2.4 was retired with it). Within one queue, RabbitMQ is FIFO per publisher channel anyway, so the normal path is ordered.
  • Push racing a disconnect. Push is best-effort after commit. The row is already stored, so the REST list and the unread count are the catch-up path (F3.1).
  • Mark-read racing the purge. 404, and that is correct — the row is gone (§3).

§6 Integration & cross-service failure — Notif F1.4 Ā· F2.2 Ā· F2.3

Scenario What happens Detection & resolution
Order commits a transition but the event is delayed notification late Order's transactional outbox means the event cannot be lost, only late; the poller retries until the broker confirms (main page §P2.5)
This service is down events accumulate durable queue + persistent messages (D8): everything waits and is processed on restart (NFR1.2); at-least-once + dedupe make the catch-up safe
Processing keeps failing retry then DLQ 3 attempts / 10 s (D6), then the durable DLQ records it for inspection and replay (F2.3) — never lost, never poisoning the consumer
Unknown event type arrives (a producer upgraded first) dead-letters intact on first rejection (D21) replay from the DLQ after the consumer's jar bump (F1.3) — the publisher needed no change
user-service down during name resolution names missing D32 degrades to actor-less copy; the 30 s negative TTL retries soon; the notifications themselves are unaffected (this service makes no outward calls — F1.1 holds with no exceptions)
Redis down at CONNECT revocation check unavailable fails open behind 2 s timeouts (G6 semantics): availability over revocation, bounded by the 1 h token TTL

None of these scenarios ever touches the producing operation (F1.4). Publishing is fire-and-forget from Order's perspective, and all the retry machinery lives on this side of the broker.

Broker decoupling (D11). All RabbitMQ code is confined to one adapter package, and an ArchUnit test enforces that. Events are broker-agnostic: identity lives in the JSON body, never in AMQP headers. Dedupe lives in the service's own DB rather than in broker features, so the design assumes only the weakest common guarantee — events may arrive twice or late. The retry/DLQ topology is the one accepted non-portable surface.

§7 Observability & demo — Notif F2.3

What exists today: one log line per event (received / dispatched / deduped / dead-lettered); the RabbitMQ management UI (localhost:15672) showing the work, retry and DLQ depths live; and the DLQ itself as the durable record of exhausted retries (F2.3). The demo steps are in the D3 demo script: a real lifecycle step pushing to the Notification Center within 5 s; the same fixture published twice from the management UI, producing one notification and a "deduped" log line; a malformed body going straight to the DLQ on first rejection; and killing the socket to show the badge re-seeding from /unread-count on reconnect.

Open items

  • Scale-out (deferred by the team since 2026-09-15). Single instance today; Redis is only read for the D23 denylist check, not used for connection state. If N5.4 (Kubernetes) ever multiplies instances, the team will then choose between Redis pub/sub and a RabbitMQ STOMP broker relay for cross-instance WebSocket fan-out.
  • CONNECT-only revocation stays an accepted limit (§4). Re-checking on a timer, or disconnecting on a revocation event, is deliberately not implemented.
  • Batch name lookup (GET /users/batch?ids=…) is noted as future work if D32's per-ID fan-out ever warrants it. Today it is bounded by the number of unique couriers in a 10-row page.

Written 2026-10-20 from the design of record; notification-service.md remains authoritative — update it first, this page after.

āš ļø **GitHub.com Fallback** āš ļø