Status: Superseded by ADR-005 (wire shape only — the
adapter-owns-its-own-domain-types principle remains in force) · Date: 2026-Q2 ·
Deciders: Aarokya Engineering
Context
The consultation module is a provider-agnostic chat-doctor pipeline. Today every consultation routes to Narayana Health, and the list-messages API contract (ConsultationMessageResponse + ConsultationMessageBody) was shaped after Narayana’s
gateway. A second provider (e.g. Apollo) will have a meaningfully different chat model —
different message kinds, attachments, lifecycle events — and the mobile client renders
its chat per provider.
We needed the message contract to differ per provider without:
- a normalized “union of everything” message type (optional fields bleeding across providers), or
- making an existing provider’s contract change when a new one is added.
Options Considered
Option 1 — Normalized canonical message model
Option 1 — Normalized canonical message model
One shared message shape; every provider maps into it; the client renders one way.Pros: client stays provider-agnostic; one endpoint, one schema.
Cons: lossy — provider-specific message kinds must be dropped or force the shape to
grow into a god-object of optional fields; the contract is dishonest when providers
genuinely diverge.
Verdict: Rejected — the product requirement is that each provider’s chat is a
distinct experience the client renders differently.
Option 2 — Per-provider endpoints
Option 2 — Per-provider endpoints
Separate operations/paths per provider (
/…/narayana/messages, /…/acme/messages),
each with its own response type.Pros: maximal separation; fully independent schemas.
Cons: the client must route by provider across N URL sets; more route surface to
maintain; the single logical “list messages” action is fragmented.
Verdict: Rejected — heavier than needed when a single endpoint with a discriminator
expresses the same thing.Option 3 — Provider-keyed discriminated union (chosen)
Option 3 — Provider-keyed discriminated union (chosen)
One endpoint; a uniform pagination envelope whose
data is a provider-tagged union,
each variant carrying that provider’s own independent types.Pros: additive (a new provider = a new variant + its own types, zero change to
existing variants); self-describing payload; one OpenAPI operation rendering oneOf +
discriminator; not a god-object.
Cons: the client becomes provider-aware (it switches on the provider tag).
Verdict: Accepted.Decision
The list-messages response is a uniform envelope; itsdata field is a provider-tagged
discriminated union. The domain layer mirrors this with provider-keyed enums, and provider
routing is derived server-side from the consultation benefit.
- API:
ConsultationMessageListResponse { data: ConsultationMessages, total, limit, offset }, whereConsultationMessagesis#[serde(tag = "provider")]. Narayana’s types live in theapi_models::consultation::narayanasubmodule. Each union variant wraps a struct (serde internally-tagged enums cannot tag a bare sequence). - Domain:
DomainThread,DomainConsultationMessages,DomainAttachmentStreamare provider-keyed enums; theConsultationProvidertrait uses associated types so each adapter declares its own output;coreconsumes them via accessors and never names a provider. - Routing: the provider kind lives on the consultation benefit’s
ConsultationBenefitDetails.provider(admin-set JSONB). The client only sendsbenefit_id; the server derives the kind from the benefit and routes on it — no slug, nobenefit_providers/consultationscolumn, no migration, no client-sent provider. - Entity stays singular: create/get/list consultation responses are unchanged — they describe our own row, not the provider’s chat.
Consequences
Gained
- Onboarding a provider is purely additive — new variant + new types, no edits to existing providers.
- Self-describing payloads with a clean OpenAPI
oneOf+providerdiscriminator. - Server-authoritative routing; the client never picks the integration.
- The consultation entity remains a single, stable contract.
Trade-offs
- The client is provider-aware: it switches on the
providertag to render each variant (a new provider needs client work). - The provider kind is mildly denormalized — each of a provider’s consultation benefits carries it (the cost of avoiding a
benefit_providerscolumn). - Codegen gained nested-module support (
@rustModule(name: "parent::child")).
Related
- ADR-001: Smithy Codegen
- Consultation module: modules/consultation, api/consultation-guide
- Backend agent notes:
backend/ai-docs/domains/consultation/AGENT-NOTES.md