ADR 0014: Notification cards carry company + job title via enrich-at-read¶
- Status: Accepted
- Date: 2026-06-26
- Deciders: jobhub-architect (story #207, ticket #212)
- Affects: notification-service, api-contracts (notification-service.yaml), application-service (new internal endpoint), JobHub-ui
Context¶
Story #207 (req 4) requires each notification card to show the tied application's company
icon (the initial-based CoLogo chip) and the job post / application name as the card's
primary identifier. Today the Notification domain model and NotificationResponse carry
only applicationId: no company name, no job title. The payload must start carrying that
data.
Two sourcing strategies are on the table:
- Enrich-at-read: resolve company + job title at response time, by calling application-service through notification-service's existing internal client/gateway layer.
- Denormalise-at-write: persist
company/job_titlecolumns on the notification row, filled by each notification writer, via a newdb/initmigration.
Relevant JobHub conventions in scope: notification-service is Hexagonal (CLAUDE.md decision
guide); api-contracts OpenAPI is the single source of truth (contract-first); the database is
owned by db/init/*.sql (schema-per-service, notification schema, migration band
040 to 049).
Two facts from surveying the code shape the decision:
- notification-service already owns an internal client + gateway layer to
application-service (
adapter/out/client/application/,AppInternalRestClient,ApplicationOwnershipGatewayAdapter, plus interest-profile / stale / upcoming gateways), guarded byX-Service-Key. Adding one more read path is incremental, not new infrastructure. - The notification writers do not uniformly have the data at write time. Ghosted-alert
writers hold
company+jobTitle(fromStaleApplicationResponse); interview-reminder writers holdcompanyName+ a next-step label (not the job title proper); custom-reminder dispatch holds onlyapplicationIdand would need a fresh resolve at write anyway. So denormalise does not avoid the upstream call, it just moves it and then freezes a copy.
The list endpoint (GET /notifications) is paginated up to 100 items, so a naive per-row
fan-out to application-service is the main risk to weigh.
Decision¶
We will enrich-at-read. notification-service resolves the tied application's company name
and job title at response-build time and populates the two new nullable
NotificationResponse fields. We will:
- Keep notification-service Hexagonal. Add one outbound port
domain/port/out/ApplicationSummaryGatewaywith a method that resolves a batch of application ids to(applicationId -> company, jobTitle), implemented by a new adapter underadapter/out/client/application/reusing the existingX-Service-Keyconfig and a client method on (or alongside)AppInternalRestClient. - Add a new internal endpoint on application-service:
GET /internal/applications/summaries?ids=...(service-key guarded) returning a compact list of{ applicationId, company, jobTitle }, so the resolve is a single batched call per page, not one call per notification (no N+1). - Resolve in the read use case after fetching the notification page: collect the distinct
non-null
applicationIds, make one gateway call, and map results onto eachNotificationResponse. Field names are frozen ascompanyandjobTitle(nullable), matching application-service's existingcompany/jobTitlenaming. - Make the resolve best-effort: on a missing, unowned, or upstream-unavailable
application, leave
company/jobTitlenull and let the UI fall back to a generic icon + label. A resolve failure must never fail the notification list response. - Make the custom-reminder title non-editable (req 4):
UpdateCustomReminderRequestdropstitle; the create-time title is preserved; onlynote,triggerAtUtc,channels,stageare editable. This is a contract change in the same freeze, no schema change.
No db/init migration is required for this story on either service: migration is N/A.
Frozen application-service contract slice (application-service.yaml)¶
This decision adds one new internal operation to application-service. It is frozen here so notification-service (consumer), application-service (producer), and the frontend all build against the same shape. Two backend developers depend on these names being final.
- Operation:
getApplicationSummaries,GET /internal/applications/summaries, tagInternal,x-implementation-status: planned, guarded by the existingserviceKeyAuth(X-Service-Keyheader), consistent with the other/internaloperations. - Query parameter: a single
idsparameter, OpenAPIstyle: form, explode: false, that is a comma-joined list (ids=<uuid>,<uuid>,...),minItems: 1,maxItems: 100. The 100 cap matches the maximum notification page size, so one page resolves in one call. Comma-join (not repeatedids=) was chosen to keep one short query string for a page of ids and to match a single batched call shape. - Response model (200):
ApplicationSummaryListResponsewrappingitems: ApplicationSummaryResponse[], whereApplicationSummaryResponseis{ applicationId (uuid), company (string), jobTitle (string) }, all required. Thecompany/jobTitlenames match application-service's existingStaleApplicationResponseconvention, so the two notification fields line up with their source. - Unknown-id behaviour: ids that do not resolve (not found, owned by another user, or
otherwise unresolvable) are omitted from
items, not returned as null entries. The response may therefore be shorter than the request, in any order, or empty. Callers map byapplicationIdand treat any absent id as unresolved (the card degrades to a generic icon + label). Omit-not-null keeps every returned item fully populated and means the notification-service mapper does not have to special-case null fields inside an item. - Status codes:
200for any partial or empty resolve (a partial result is success, not an error);400on missingids, a malformed UUID, an empty list, or more than 100 ids;401on a missing or invalidX-Service-Key(the standard/internalguard);500on an unexpected server error. No404: a fully-unresolved batch is still a200with emptyitems, since per-id resolution is best-effort by design. - Read-only, no schema change: the endpoint is a read-only aggregation over the existing
applicationsschema.companyandjobTitlecome from the same columns the stale and upcoming-next-steps internal endpoints already read (the crawled-job snapshot'stitle/companyor the manual-entry job'stitle/company). No new columns, no DDL, so nodb/initmigration on application-service (the next free application number remains unused by this story).
Consequences¶
- Positive: notification rows stay a pure event log, with no denormalised copies to keep consistent. If a user edits an application's company or job title, every existing notification card reflects the new value automatically (consistency is free).
- Positive: no schema migration, no backfill of historical rows, no new write-path coupling in five notification writers.
- Positive: reuses the existing internal client/gateway +
X-Service-Keypattern already proven in notification-service; the layering stays clean (new port indomain/port/out/, new adapter inadapter/out/client/application/). - Negative / cost: the notification list now has a runtime dependency on application-service. Mitigated by (a) a single batched call per page and (b) best-effort degradation so the list still renders if application-service is slow or down.
- Negative / cost: small added read latency per page (one extra internal round trip). Acceptable for an interactive list; can be cached later if it ever matters.
- Follow-ups:
- Backend (#215): new
GET /internal/applications/summarieson application-service (its own contract slice + handler), newApplicationSummaryGatewayport + adapter + client method in notification-service, populatecompany/jobTitleinNotificationResponseDto.from(...), drop title handling on the custom-reminder update use case. WireMock for the new client (notification-service already uses WireMock). - Frontend (#216): render
CoLogofromcompany, showjobTitleas the primary identifier, graceful fallback when both are null, drop the title field from the custom-reminder edit form (body-only). - QAE: component test for the batched resolve, the null/degraded path, and the body-only reminder update.
Alternatives considered¶
- Denormalise-at-write (persist
company/job_titleon the notification row, new04Nmigration): rejected. It does not remove the upstream call (custom-reminder and interview writers still must resolve at write), it adds a schema migration plus a backfill for existing rows, it freezes a stale copy that diverges when the application is later edited, and it spreads resolve logic across five separate writers. The only win is read latency, which the batched single-call-per-page read already keeps small. - Per-row resolve at read (one application-service call per notification): rejected as
the obvious N+1 on a 100-item page. The batched
summaries?ids=...endpoint gives the same freshness for one round trip per page. - Embed company/job on the existing
applicationIddeep-link only (no card fields): rejected because it does not satisfy req 4, which needs the data on the card itself before the user navigates.