A patient taps a kiosk and asks for help. A staff member replies from another screen. The same message may appear in the app, trigger a push notification, fall back to Short Message Service (SMS), and later show as read.
If each screen decides what those events mean, the thread turns into a rumor mill. One client marks the message as sent. Another treats a push attempt as delivery. A third retries after a network pause. In a clinic, that ambiguity creates audit gaps, extra staff work, and a patient expectation problem: sent must mean something actionable.
The rule I wanted was simple: every message has a durable state, every transition has an owner, and urgent communication can leave the app channel when policy allows it.
That rule is why I built BidirectionalMessagingService as the coordination layer for kiosk messaging, rather than treating chat as a screen-level feature.
The invariant comes before the interface
The kiosk already had services for push, SMS, email, and templates. Those services deliver through channels. They do not decide the truth of the thread.
BidirectionalMessagingService owns the message lifecycle: creation, delivery policy, acknowledgement handling, read receipts, fallback decisions, and teardown. Push, SMS, and email remain transports.
flowchart TD
patient[Patient Kiosk] --> service[BidirectionalMessagingService]
staff[Staff Client] --> service
service --> thread[Message Record]
service --> push[Push Notification]
service --> sms[SMS Fallback]
service --> email[Email Delivery]
service --> receipt[Read Receipt]```
The cost is ceremony. A basic widget can append text and look finished. This version asks callers to provide role, priority, permitted channels, and acknowledgement behavior. That extra shape buys one authority for changing the record.
## The row carries the user-facing truth
The message model stores identity, direction, status, timestamps, and attachments. The durable status set is intentionally small: `sent`, `delivered`, `read`, and `failed`.
Queue membership stays in memory while work is active. It never becomes a database value. A queued item is a worker condition, not a patient-visible fact. Persisting it would force every client to explain whether the item is safe, blocked, or retrying.
Delivery options are policy, rather than a single flag:
| Field | What it decides | Cost |
|---|---|---|
| `channels` | Which transports may carry the message | More combinations to test |
| `urgency` | How aggressively the system alerts users | Greater risk of alert fatigue |
| `requireDeliveryConfirmation` | Whether delivery needs acknowledgement | More bookkeeping |
| `fallbackToSMS` | Whether the message may leave the app channel | Higher external dependency surface |
| `translationLanguage` | Whether content needs language adaptation | More transformation risk |
Timeouts live in the same policy layer. In my implementation, the clock starts with the active delivery attempt. If that attempt expires and no permitted route can prove delivery, the message resolves to `failed`. Presence can influence routing, but it does not create another stored status.
## Receipts and teardown are side effects
A read action moves the message to `read`. Notifying the sender is a side effect of that transition. If the sender notification fails, the patient still read the message. Mixing those facts would make the history less reliable.
The same ownership applies to runtime resources. Realtime subscriptions and pending work need an explicit end, so the service owns cleanup instead of scattering it across screens. In the implementation, cleanup unsubscribes active channels, clears channel tracking, and clears the in-memory message queue. That prevents stale listeners from interpreting later kiosk activity.
That matters in a shared device flow. One patient can walk away, another can begin check-in, and an old subscription should have no vote in the new session.
## The review table
Once the lifecycle is explicit, each operation has one test: it changes the durable record, triggers a side effect, or both.
| Event | Owner | Durable status outcome | Side effects |
|---|---|---|---|
| Staff sends message | Messaging service | `sent` | Channel delivery attempts |
| Transport confirms delivery | Channel adapter | `delivered` | Optional confirmation notice |
| Patient opens message | Messaging service | `read` | Read receipt notification |
| Active attempt expires | Messaging service | `failed` | Optional fallback path before failure |
| Sender receives receipt notice | Receipt handler | No new message status | User interface acknowledgement |
Consumer chat can tolerate fuzzy indicators. A clinic kiosk has less room for soft meaning because staff coordinate care through the thread and patients expect a reply to reach someone. With explicit states and owners, one status record survives channel retries, fallback routing, and client differences. The interface can vary; the thread still tells one story.
---
๐ง **Listen to the audiobook** โ [Spotify](https://open.spotify.com/show/4ABVd5yDVfbX9HlV5JjT7D) ยท [Google Play](https://play.google.com/store/audiobooks/details/How_to_Architect_an_Enterprise_AI_System_And_Why_t?id=AQAAAECafz8_tM&hl=en) ยท [All platforms](https://www.craftedbydaniel.com/audiobook)
๐ฌ [Watch the visual overviews on YouTube](https://youtube.com/playlist?list=PLRteDbGJPYDb9XNjecvHplGlgW7tIv_q6)
๐ [Read the full 13-part series](https://www.craftedbydaniel.com/blog/series/how-to-architect-an-enterprise-ai-system-and-why-the-engineer-still-matters)
Top comments (0)