communication-service is a Quarkus service that owns all outbound and in-app notifications for ShopSTAR3. Storage is PostgreSQL with store_id as a discriminator column for multi-tenant isolation. Unread in-app notification counts are cached in Redis per {recipient_id, store_id}. All external provider credentials are stored in Vault — they are never written to the database.
Channel Model#
Channels are open VARCHAR strings, not a closed enum. The platform ships a set of well-known constants — EMAIL, SMS, WHATSAPP, PUSH, IN_APP — but any string is a valid channel provided a matching adapter is registered.
ChannelProvider Interface#
All channel adapters implement a single interface:
interface ChannelProvider {
SendResult send(OutboundMessage message);
}A ChannelProviderRegistry maps each channel string to its adapter at runtime. To add a custom channel (e.g. SLACK, TELEGRAM, LINE, CUSTOM_WEBHOOK), deploy a new class implementing ChannelProvider and call registry.register() at startup. No database schema migration is required.
An attempt to save a routing configuration referencing an unregistered channel is rejected at the API layer with HTTP 400. The check is registry.isRegistered(channel) before the row is persisted.
Built-in Adapters#
| Channel | Adapters |
|---|---|
EMAIL | SendGridAdapter, AwsSesAdapter |
SMS | TwilioSmsAdapter, UnifionicAdapter |
WHATSAPP | TwilioWhatsAppAdapter, ThreeSixtyDialogAdapter |
PUSH | FcmAdapter, ApnsAdapter |
IN_APP | Internal — INSERT directly into in_app_notifications table; no external provider call |
Delivery Modes#
Mode 1 — Synchronous gRPC#
Used for critical, time-sensitive messages where the caller must know delivery was accepted before continuing: password reset links, GDPR export-ready notifications, critical staff alerts.
The caller invokes the SendTransactional gRPC method and waits for a SendResult before proceeding.
Mode 2 — Asynchronous Kafka (Fire-and-Forget)#
Messages are published to one of three priority-partitioned topics. Consumer thread counts are sized to their expected throughput and latency tolerance.
| Topic | Consumer Threads | Use Cases |
|---|---|---|
notification.requested.critical | 8 | Critical-path messages dispatched asynchronously |
notification.requested.transactional | 16 | Order lifecycle events, loyalty points earned |
notification.requested.marketing | 4 | Cart recovery, review requests, campaigns |
Event contract:
{
"type": "string",
"recipientId": "uuid",
"storeId": "uuid",
"payload": {},
"idempotencyKey": "string",
"priority": "CRITICAL | TRANSACTIONAL | MARKETING"
}Mode 3 — Deferred Dispatch#
Deferred messages use the same Kafka contract with three additional fields:
| Field | Type | Purpose |
|---|---|---|
sendAt | TIMESTAMPTZ | When to dispatch the message |
expiresAt | TIMESTAMPTZ | Safety net — if still PENDING at expiry, mark EXPIRED rather than send stale |
correlationId | VARCHAR | Groups related deferred steps for bulk cancellation |
On receipt of a deferred event, communication-service INSERTs a row into scheduled_notifications with status PENDING. An internal scheduler job polls rows where send_at <= now() and status = PENDING, then dispatches them through the standard dispatch flow.
Mode 4 — Deferred Cancellation#
When a triggering event is no longer relevant, the originating service publishes a notification.cancelled event carrying the correlationId. Communication-service marks all matching PENDING rows in scheduled_notifications as CANCELLED.
The expiresAt field is a passive safety net: rows that are still PENDING past their expiry are marked EXPIRED by the scheduler and never sent.
Who publishes notification.cancelled:
| Cancelled Notification | Publisher | Trigger |
|---|---|---|
| Cart recovery (all steps) | marketing-service | order.placed |
| Review request | review-service | Review submitted |
| Payment pending reminder | order-service | Order confirmed or cancelled |
| Back-in-stock alert | inventory-service | inventory.back_in_stock |
Data Model#
channel_provider_configs#
Per-store, per-channel provider binding. Columns: config_id, store_id, channel VARCHAR, provider_type VARCHAR, is_active, from_address, settings JSONB. UNIQUE constraint on (store_id, channel). Credentials are stored in Vault and referenced by provider_type, not stored in this table.
notification_routing#
Store-configurable routing rules that determine which channels are active for each message type. Columns: routing_id, store_id (NULL = platform default), message_type VARCHAR, channel VARCHAR, enabled BOOLEAN, priority VARCHAR.
Resolution: if a row exists for (store_id, message_type, channel), it overrides the platform default row for the same (message_type, channel).
message_templates#
Template definitions per message type, channel, and locale. Columns: template_id, store_id (NULL = platform default), message_type, channel VARCHAR, locale VARCHAR(10), subject VARCHAR, body TEXT. The body field uses {{variable}} placeholders for payload injection.
Template resolution order (first match wins):
- Store override — exact locale match
- Store override — default locale
- Platform default — exact locale match
- Platform default —
"en"
customer_consents#
Per-customer, per-store, per-channel consent record. The channel column is VARCHAR, consistent with the open channel model. Status values: OPTED_IN, OPTED_OUT, NOT_SET.
- Transactional messages: consent check is bypassed entirely.
- Marketing messages: dispatched only if status is
OPTED_IN. Otherwise the message is logged asSKIPPEDwithskip_reason = OPTED_OUT.
scheduled_notifications#
Stores deferred and multi-step drip campaign records. Key columns:
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
store_id | UUID | |
correlation_id | VARCHAR | Groups related steps for cancellation |
message_type | VARCHAR | |
step | INT | Position within a multi-step drip sequence |
recipient_id | UUID | |
payload | JSONB | |
idempotency_key | VARCHAR UNIQUE | Prevents duplicate dispatch |
status | ENUM | PENDING / SENT / CANCELLED / EXPIRED |
cancel_reason | VARCHAR | Populated on cancellation |
send_at | TIMESTAMPTZ | Dispatch threshold |
expires_at | TIMESTAMPTZ | Safety-net expiry |
Indexes: (correlation_id, status) for bulk cancellation lookups; (send_at, status) for scheduler polling.
push_tokens#
| Column | Notes |
|---|---|
customer_id | |
store_id | |
platform | FCM or APNS |
token | Raw device token |
device_id | Used as the deregistration key |
last_used_at | Updated on each successful push |
Tokens are registered on customer login and deregistered on logout. Invalid tokens (rejected by FCM or APNs) are pruned on the first provider failure.
in_app_notifications#
| Column | Notes |
|---|---|
notification_id | UUID PK |
store_id | |
recipient_id | |
recipient_type | CUSTOMER or STAFF |
title | |
body | |
link_url | Nullable |
is_read | BOOLEAN |
created_at | |
read_at | Nullable |
Unread counts are maintained in Redis per {recipient_id, store_id} and incremented on INSERT / decremented on read.
message_log#
Audit record for every dispatched or skipped message. Raw PII (email address, phone number) is never stored in this table — only opaque identifiers.
| Column | Notes |
|---|---|
message_id | UUID PK |
store_id | |
customer_id | Opaque reference only — no raw PII |
channel | VARCHAR |
message_type | VARCHAR |
category | TRANSACTIONAL / MARKETING / OPERATIONAL |
provider_type | VARCHAR |
provider_message_id | Returned by the external provider |
idempotency_key | VARCHAR UNIQUE |
status | QUEUED / SENT / DELIVERED / FAILED / BOUNCED / SKIPPED |
skip_reason | OPTED_OUT / NO_TOKEN / NO_ADDRESS / UNKNOWN_CHANNEL |
template_id | FK to message_templates |
timestamps | Created, sent, delivered |
Rows are purged when a customer.erasure_requested event is received.
Dispatch Flow#
The following sequence applies to all non-deferred messages, regardless of whether the trigger arrived via Kafka or synchronous gRPC.
- Receive trigger — Kafka
notification.requested.*consumer or gRPCSendTransactionalhandler. - Idempotency check — if
message_logalready containsidempotencyKeywith statusSENTorQUEUED, skip processing and return immediately. - Deferred branch — if
sendAtis present, INSERT aPENDINGrow intoscheduled_notificationsand return. The scheduler will dispatch it later. - Resolve recipient contact — call customer-service via gRPC to retrieve email address, phone number, locale, and push tokens.
- Resolve routing — query
notification_routingfor(store_id, messageType)to obtain the list of enabled channels and their priorities. - Consent check — for each channel: if
category = TRANSACTIONAL, skip check; ifcategory = MARKETING, verifyOPTED_INor logSKIPPED. - Resolve template — apply the four-step resolution order (store override → platform default, locale-aware).
- Inject variables — render
{{variable}}placeholders usingpayloadfields and customer context. - Dispatch — call
ChannelProviderRegistry.resolve(channel).send(message). ForIN_APP, INSERT directly intoin_app_notifications; no external provider is called. - Write message_log — record status
SENTorFAILED. When the provider delivery webhook fires, UPDATEstatus = DELIVERED.
gRPC Interface#
| Method | Signature | Purpose |
|---|---|---|
SendTransactional | (type, recipientId, storeId, payload, idempotencyKey) → SendResult | Synchronous send for critical messages |
RegisterPushToken | (customerId, storeId, platform, token, deviceId) → RegisterResult | Register a device push token on login |
DeregisterPushToken | (customerId, storeId, deviceId) → DeregisterResult | Remove a device token on logout |
GetNotifications | (recipientId, storeId, page, pageSize) → NotificationsResponse | Paginated in-app notification list |
MarkNotificationsRead | (notificationIds[]) → MarkReadResult | Mark notifications read; updates Redis unread count |
REST Interface (Admin SPA)#
Full CRUD over REST for: template management (per store, per channel, per locale), channel provider configuration, notification routing rules, bulk campaign creation and scheduling, consent management, message log (read-only), and push token management.
Kafka Topics#
Consumed#
| Topic | Action |
|---|---|
notification.requested.critical | Dispatch with 8-thread consumer group |
notification.requested.transactional | Dispatch with 16-thread consumer group |
notification.requested.marketing | Dispatch with 4-thread consumer group |
notification.cancelled | Mark matching PENDING scheduled_notifications as CANCELLED by correlationId |
customer.erasure_requested | Purge all message_log and in_app_notifications rows for the customer |
Published#
| Topic | Payload |
|---|---|
communication.sent | messageId, channel, messageType, customerId, storeId |
communication.failed | messageId, channel, messageType, customerId, storeId, reason |
Message Type Registry#
Message types (ORDER_CONFIRMED, CART_RECOVERY, etc.) are not hardcoded in communication-service. Domain services declare their own message types, and communication-service learns about them at runtime.
How types are registered#
Each domain service implements the MessageTypeProvider interface (from ss3-quarkus). On startup, the extension’s NotificationMessageTypeRegistrar calls the RegisterMessageTypes gRPC method on communication-service with all declared types in a single batch. Communication-service upserts them into message_type_definitions and loads them into an in-memory MessageTypeRegistry.
The in-memory registry is seeded from the DB at communication-service startup, so registered types survive restarts without waiting for domain services to re-register.
What a message type declaration carries#
| Field | Purpose |
|---|---|
type | Machine-readable key — SCREAMING_SNAKE_CASE, e.g. ORDER_CONFIRMED |
name | Human-readable label shown in admin routing and template screens |
description | Describes when the notification fires |
defaultPriority | CRITICAL, TRANSACTIONAL, or MARKETING — pre-populates routing config |
category | TRANSACTIONAL or MARKETING — determines whether consent is checked |
payloadKeys | Variables the publisher includes (e.g. orderId, total) — used to validate template {{variables}} at save time |
Unknown types → dead-letter#
If a notification.requested.* event arrives with a type not present in the registry, it is routed to notification.requested.dlq rather than processed. An ops-alerting consumer monitors the DLQ and fires an alert on any arrival. This catches typos and services that publish before registering.
Template variable validation#
When a store admin saves a template, communication-service checks that every {{variable}} in the body and subject exists in the payloadKeys declared for that message type. Templates referencing undeclared variables are rejected at save time.
Channel Registry#
Channels are self-registering. Each ChannelProvider adapter calls ChannelProviderRegistry.register() at CDI startup, passing a ChannelDefinition alongside the adapter instance. The registry persists the definition to a channel_definitions table so the admin UI has a stable source of truth across restarts.
Channel definition metadata#
| Field | Purpose |
|---|---|
channel | Open string key — e.g. EMAIL, SLACK |
name | Human-readable label in admin UI |
description | What the channel does |
requiresProviderConfig | Whether the channel needs store-level credentials (EMAIL yes; IN_APP no) |
contentType | SUBJECT_AND_BODY (email), BODY_ONLY (SMS/push), STRUCTURED_PAYLOAD (in-app/webhook) — controls which template fields the editor shows |
isBuiltIn | true for platform-shipped channels; built-in channels cannot be removed via admin UI |
Admin UI discovery#
GET /api/v1/message-types list all registered types (filterable by category)
GET /api/v1/channels list all registered channelsThese power the routing configuration screen (select type + channel + priority), the template editor (pre-populate available {{variables}}), and the provider config screen (show only channels where requiresProviderConfig = true).
Vault Configuration#
Channel provider credentials are not in Vault. They are per-store, per-channel configuration — store admins supply their own provider accounts (SendGrid API key, Twilio credentials, etc.) via the admin UI, and they are stored in channel_provider_configs. Vault holds only the service’s own infrastructure credentials.
ss3/kv/communication-service/
db.url
db.username
db.password