Appearance
Provider abstraction architecture ​
Last updated: 2026-06-18 Status: Current — derived from ADRs 0004, 0010, 0013, and 0024.
Purpose ​
Heritage Community Hub is built for a small faith community of roughly 200 members. Azure is the active infrastructure provider today. That choice is intentional, and there is no current plan to leave Azure. However, several observations during the Phase-0 decision gate (documented in ADR 0024) made clear that structuring the codebase without deliberate seams would allow provider-specific dependencies — Azure SDK imports, SQL Server dialect, host bindings — to accumulate inside business logic. Reversing that later would require a partial rewrite, not a configuration change.
The portable-by-design principle, accepted in ADR 0024, draws from this: the codebase wraps every cloud-provider primitive behind a thin interface so that the rest of the application has no dependency on a specific provider. Azure is what runs behind those interfaces right now. Swapping a provider means rewriting one adapter module and updating connection strings — it does not mean touching route handlers, service classes, or domain entities.
This document explains the four abstraction seams, the pattern each one follows, and what is deliberately left outside the abstraction.
The four abstraction seams ​
ADR 0024 identifies four places where provider-specific dependencies can bleed into application code if uncontrolled. Each seam has its own interface; Azure provides the initial adapter for each one.
text
┌──────────────────────────── apps/api ──────────────────────────────┐
│ Route handlers / service classes — no provider SDK imports │
│ (domain logic in plain TypeScript) │
└────────┬──────────────┬──────────────┬──────────────┬──────────────┘
│ │ │ │
┌──────▼──────┐ ┌─────▼──────┐ ┌────▼───────┐ ┌───▼────────────┐
│ DatabaseRepo│ │StorageAdapt│ │NotifAdapter│ │SecretsAdapter │
│ (ORM + repo)│ │StorageProvi│ │SmsProvider │ │SecretsProvider │
│ │ │der interface│ │EmailProvider│ │getSecret(name) │
└──────┬──────┘ └─────┬──────┘ └────┬───────┘ └───┬────────────┘
│ │ │ │
Azure DB for Azure Blob Twilio / Azure Key
PostgreSQL Storage SendGrid Vault
Flexible Server (Cloudflare R2 (Expo push
as cost-down is provider-
swap) neutral)Diagram: each of the four seams is an interface that the application layer crosses without knowing which concrete adapter is registered beneath it.
Database ​
Interface: an Object-Relational Mapping (ORM) repository layer with no raw provider-dialect SQL in business logic.
Active provider: Azure Database for PostgreSQL Flexible Server (decided, ADR 0024; supersedes the Azure SQL Serverless choice in ADR 0004).
The decisive factor for the database was dialect portability. Azure SQL Serverless uses the SQL Server dialect (T-SQL): functions such as GETDATE(), TOP, and SQL Server-specific JSON path syntax do not exist in standard SQL or Postgres. Migrating from T-SQL to any other engine requires schema translation and query rewrites — it is not an adapter swap.
Postgres runs identically on Azure Database for PostgreSQL, AWS RDS (Relational Database Service), GCP Cloud SQL, Supabase, and self-hosted servers. The Postgres dialect moves with the codebase without modification. Switching from Azure Database for PostgreSQL to a different managed Postgres host is a connection-string change.
The schema is defined as a Prisma schema file with provider = "postgresql". Prisma abstracts day-to-day query work; raw SQL, if ever needed, is written in standard Postgres dialect, not T-SQL. No raw dialect SQL may appear outside the repository layer.
Portable alternatives: AWS RDS for PostgreSQL, GCP Cloud SQL for PostgreSQL, Supabase, self-hosted Postgres.
Storage ​
Interface: StorageProvider — defined in ADR 0010 (Sermons & Music Hub) and adopted by ADR 0024 as a platform-level interface. Three operations:
typescript
// UNVALIDATED — interface has not been compiled against a running build
interface StorageProvider {
putObject(key: string, stream: Readable, contentType: string): Promise<void>;
deleteObject(key: string): Promise<void>;
issueSignedUrl(key: string, ttlSeconds: number): Promise<string>;
}Active provider: Azure Blob Storage (Hot tier), with Cloudflare R2 (zero egress cost) identified as the cost-down swap when monthly egress exceeds the $20–30 trigger documented in ADR 0010.
The signed-URL pattern (issueSignedUrl) is the mechanism that enforces member-only content access. When a member requests a media file, the API validates the caller's Clerk session and role (ADR 0003, ADR 0006), then calls issueSignedUrl to obtain a short-lived URL scoped to that specific object. The client plays or downloads from that URL directly; the URL expires automatically. Signed URLs are never stored. A public URL is never issued.
All storage backends that are considered viable substitutes — Azure Blob (Shared Access Signature, or SAS), AWS S3 (presigned URL), Google Cloud Storage (signed URL), Cloudflare R2 (S3-compatible signed URL), MinIO (S3-compatible signed URL) — implement the same signed-URL pattern, so issueSignedUrl maps directly to the backend's native mechanism. Switching storage backends does not change the RBAC logic or the client API contract.
The platform API is the only writer to storage. No client or background process calls storage directly.
Portable alternatives: Cloudflare R2 (preferred cost-down swap), AWS S3, Google Cloud Storage, MinIO.
Notifications ​
Interfaces: SmsProvider and EmailProvider. Mobile push is handled by the Expo Push API (which routes to Apple Push Notification service, APNs, and Firebase Cloud Messaging, FCM) and is provider-neutral by construction. In-app notifications write directly to the Notifications table in Postgres.
typescript
// UNVALIDATED — interfaces have not been compiled against a running build
interface SmsProvider {
sendSms(to: string, body: string): Promise<void>;
}
interface EmailProvider {
sendEmail(to: string, subject: string, htmlBody: string): Promise<void>;
}Active providers:
- SMS — Twilio (decided, ADR 0013; owner-confirmed 2026-06-18). Telnyx is documented in ADR 0013 as the cost-down drop-in at roughly half the per-segment cost.
- Email — SendGrid free tier.
- Push — Expo Push API (free tier; no adapter interface required because Expo already abstracts APNs and FCM).
The SMS provider choice deliberately excludes Azure Communication Services (ACS). ACS is an Azure-locked resource: if the platform relocates to AWS, GCP, or on-premises, ACS cannot move with it. SMS must keep working unchanged regardless of the compute or storage host. Twilio and Telnyx are cloud-neutral; they run identically from any host. This constraint is stated explicitly in ADR 0013 and ADR 0024.
Swapping the SMS provider from Twilio to Telnyx — or to any other carrier-grade provider — is a configuration change: update the SmsProvider adapter and rotate the API key in Azure Key Vault. No application code outside the adapter changes.
The notification transport (POST /notifications/send) fans out to all active channels concurrently per recipient. A failure on one channel does not block the others. This shared transport is a platform service (ADR 0008, responsibility 4); features call it and do not contact providers directly.
Portable alternatives: Telnyx (SMS cost-down swap), Plivo (SMS); Mailgun, Postmark (email). Push routing through Expo does not change regardless of the underlying APNs/FCM.
Compute ​
Interface: the Open Container Initiative (OCI) container standard — a Docker image. There is no interface file for compute; the portability comes from containerization itself.
Active provider: Azure Container Apps.
ADR 0024 supersedes the ADR 0004 compute choice of Azure Functions (Consumption). Azure Functions uses host-specific trigger decorators and context.bindings from the @azure/functions package. These are not portable: code written with Functions bindings cannot run on AWS Fargate, GCP Cloud Run, or a plain container host without rewriting the binding layer.
A containerized Node.js API (Express or Fastify) has no host-specific imports in route code. The same Docker image runs in the local development environment, in the GitHub Actions continuous integration (CI) pipeline, and on Azure Container Apps in production. Moving to another OCI-compatible host means updating the deployment target — the image is unchanged.
Azure Container Apps free tier (180,000 vCPU-seconds and 360,000 GiB-seconds per month) is sufficient for a community of roughly 200 members with light API traffic. This must be verified against actual traffic during Phase 1 infrastructure work (AB#3074) before provisioning.
Portable alternatives: AWS Fargate/ECS (Elastic Container Service), GCP Cloud Run, self-hosted Kubernetes, any OCI-compatible runtime.
Adapter pattern ​
How an adapter is structured ​
Each seam follows the same structure:
- An interface is declared in
packages/shared-types(or inapps/api/src/adapters/where the interface is internal to the API only). The interface contains only operations the application actually needs — it is kept minimal. - A concrete adapter class implements the interface and imports the provider SDK. The SDK import is confined to that adapter file; it does not appear anywhere else in the codebase.
- The adapter is registered at API startup and injected into the service or middleware that uses it.
Adapter registration at startup ​
Adapters are resolved once when the API process starts, using the environment variables described below. The resolved instances are placed in a dependency-injection container (or passed as constructor arguments) from which route handlers and services receive them. This means the adapter selection is a startup concern, not a per-request concern.
A simplified version of the startup pattern:
typescript
// UNVALIDATED — illustrative only; exact DI mechanism is a Phase 1 implementation decision
import { AzureBlobStorageAdapter } from './adapters/storage/azure-blob';
import { TwilioSmsAdapter } from './adapters/sms/twilio';
import { SendGridEmailAdapter } from './adapters/email/sendgrid';
import { AzureKeyVaultAdapter } from './adapters/secrets/azure-keyvault';
function buildAdapters(): Adapters {
return {
storage: new AzureBlobStorageAdapter(process.env.STORAGE_CONTAINER_URL!),
sms: new TwilioSmsAdapter(process.env.TWILIO_ACCOUNT_SID!, process.env.TWILIO_AUTH_TOKEN!),
email: new SendGridEmailAdapter(process.env.SENDGRID_API_KEY!),
secrets: new AzureKeyVaultAdapter(process.env.AZURE_KEYVAULT_URL!),
};
}Environment-variable-driven selection ​
The active adapter for each seam is controlled entirely by environment variables. No adapter-selection logic lives inside business code. In practice, the initial build has only one adapter per seam (the Azure adapter), so selection logic is trivial. If a second adapter is written for a seam, an ADAPTER_STORAGE=cloudflare-r2 style variable can route to it without a code change — only a new concrete class and updated startup wiring.
All secrets needed by adapters (API keys, connection strings) are stored in Azure Key Vault (kv-hcs-vault-01) and injected at runtime by the containerized API. They are never committed to source.
Lint enforcement ​
The boundary between adapter code and application code is enforced by the ESLint rule import/no-restricted-paths. Once the adapter directories are established in Phase 1, this rule is configured so that any import of @azure/storage-blob, @azure/keyvault-secrets, twilio, @sendgrid/mail, or any other provider SDK from outside the designated adapter directories fails the lint check. This is documented in the contribution guide and checked at code review.
What is not abstracted ​
Two dependencies are deliberately left outside the adapter pattern.
Clerk (adult authentication). Clerk is the OpenID Connect (OIDC) provider for adult social login (ADR 0003). It touches two points in the codebase: the client-side Expo SDK (used for the native Apple/Google sign-in prompt) and the server-side JSON Web Token (JWT) verification call that validates the Clerk session before each protected request. Abstracting Clerk behind an interface would require abstracting the entire authentication flow — the token issuance, the JWKS (JSON Web Key Set) endpoint call, and the session lifecycle — for a marginal gain. The portability cost of Clerk is low: it is a SaaS product with no Azure-specific binding, meaning it works unchanged regardless of which cloud hosts the API.
Critically, Clerk never touches child accounts (ADR 0007). Children authenticate against platform-owned credentials (Argon2id hash stored in Postgres). The COPPA (Children's Online Privacy Protection Act) boundary is clean by construction: no child personally identifiable information (PII) ever reaches Clerk or any external identity provider. Maintaining this boundary is a hard constraint throughout implementation — it is not negotiable in the interest of interface symmetry.
Azure DevOps (ADO). ADO Boards is the work-tracking tool for this project. No ADO SDK or API call exists inside the application codebase. ADO is internal platform tooling used by the engineering team; it has no runtime presence in the deployed application.
Related ADRs ​
| ADR | Title | What it decides for this topic |
|---|---|---|
| ADR 0004 | Cloud/hosting stack, CI/CD, and free-tier path | Original stack choices; compute and database sections superseded by ADR 0024; CI/CD, web hosting, storage, and Key Vault remain |
| ADR 0008 | Platform composition | API-first rule — no provider SDK import may appear in a surface or a feature; adapters are part of the platform core |
| ADR 0010 | Sermons and Music Hub | Origin of the StorageProvider interface; signed-URL delivery pattern and member-only access gate |
| ADR 0013 | Notification transport | SmsProvider (Twilio, decided) and EmailProvider (SendGrid) adapter decisions; rejection of Azure Communication Services on portability grounds |
| ADR 0024 | Cloud portability and provider abstraction | Portable-by-design decision; the four seams; Postgres over Azure SQL; containers over Azure Functions; the import/no-restricted-paths rule |