Appearance
Repository and provider adapters ​
This tutorial walks through how to define and wire the four provider-abstraction seams established in ADR 0024: the database repository layer, the storage adapter, and the notification adapters (SMS and email). By the end you will understand the pattern, see representative TypeScript implementations for each seam, and know how the application selects a concrete adapter at startup.
Prerequisites. You should be familiar with the monorepo layout (ADR 0001), the API-first composition model (ADR 0008), and the canonical Plane-2 role model (ADR 0006). The tutorial assumes TypeScript in strict mode and pnpm workspaces.
Purpose — the portability principle (ADR 0024) ​
ADR 0024 identifies four cloud-provider lock-in seams: compute, database, storage, and secrets. Three of those four are addressed through code patterns rather than infrastructure decisions:
| Seam | Abstraction | Active provider |
|---|---|---|
| Database | ORM + repository interface | Postgres on Azure Database for PostgreSQL Flexible Server |
| Storage | StorageProvider interface | Azure Blob Storage (initial); Cloudflare R2 as cost-triggered swap |
| SMS | SmsProvider interface | Twilio (ADR 0013; Telnyx documented as cost-down drop-in) |
EmailProvider interface | SendGrid free tier (ADR 0013) |
The governing rule from ADR 0024: provider SDK imports are permitted only inside their adapter module. No route handler, service class, or domain entity may import @azure/storage-blob, twilio, @sendgrid/mail, or any database driver directly. A failing lint rule (import/no-restricted-paths) will enforce this once the adapter directories are established.
The practical benefit is that migrating a provider means updating one adapter file and one environment variable — not touching business logic.
Database repository pattern ​
The platform uses Postgres via Prisma ORM (ADR 0024). Prisma already abstracts the SQL dialect. The repository layer adds one further level of indirection: it hides even the Prisma client type from service classes, so tests can substitute a plain TypeScript fake without a database connection.
Defining an interface (UserRepository) ​
Define the interface in the shared packages/types workspace package so both the API and any future CLI or worker can depend on it without importing Prisma.
typescript
// packages/types/src/repositories/user-repository.ts
// UNVALIDATED
export interface User {
id: string;
clerkSubject: string; // Clerk JWT `sub` claim — the only trusted client identifier
role: 'admin' | 'ministry_leader' | 'group_leader' | 'member' | 'visitor' | 'comms_author';
status: 'active' | 'pending_approval' | 'suspended';
email: string;
phone: string | null; // required for adults after A2P activation; null until verified
createdAt: Date;
}
export interface CreateUserInput {
clerkSubject: string;
email: string;
}
export interface UserRepository {
findById(id: string): Promise<User | null>;
findByClerkSubject(sub: string): Promise<User | null>;
create(input: CreateUserInput): Promise<User>;
updateRole(id: string, role: User['role']): Promise<User>;
updateStatus(id: string, status: User['status']): Promise<User>;
}The interface carries no Prisma types. A service class declares a constructor parameter of type UserRepository and never knows which database is behind it.
Implementing with Postgres (Drizzle ORM) ​
The concrete implementation lives in apps/api/src/adapters/db/ — the only place allowed to import the ORM client.
typescript
// apps/api/src/adapters/db/postgres-user-repository.ts
// UNVALIDATED
import { eq } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { users } from '../../schema/users.js';
import type { CreateUserInput, User, UserRepository } from '@heritage/types';
export class PostgresUserRepository implements UserRepository {
constructor(private readonly db: NodePgDatabase) {}
async findById(id: string): Promise<User | null> {
const rows = await this.db.select().from(users).where(eq(users.id, id)).limit(1);
return rows[0] ?? null;
}
async findByClerkSubject(sub: string): Promise<User | null> {
const rows = await this.db
.select()
.from(users)
.where(eq(users.clerkSubject, sub))
.limit(1);
return rows[0] ?? null;
}
async create(input: CreateUserInput): Promise<User> {
const rows = await this.db
.insert(users)
.values({ clerkSubject: input.clerkSubject, email: input.email })
.returning();
return rows[0];
}
async updateRole(id: string, role: User['role']): Promise<User> {
const rows = await this.db
.update(users)
.set({ role })
.where(eq(users.id, id))
.returning();
return rows[0];
}
async updateStatus(id: string, status: User['status']): Promise<User> {
const rows = await this.db
.update(users)
.set({ status })
.where(eq(users.id, id))
.returning();
return rows[0];
}
}Note that the Drizzle import (drizzle-orm/node-postgres) does not appear anywhere outside this file. The NodePgDatabase type is used only in the constructor signature, which is visible only within the adapter directory.
Registering repositories at startup (dependency injection) ​
The API entry point creates concrete adapter instances and passes them into service classes through constructor injection. No service class calls new PostgresUserRepository() itself — it receives the repository as a parameter.
typescript
// apps/api/src/server.ts (startup wiring excerpt)
// UNVALIDATED
import { drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import { PostgresUserRepository } from './adapters/db/postgres-user-repository.js';
import { UserService } from './services/user-service.js';
import { buildRoutes } from './routes/index.js';
const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] });
const db = drizzle(pool);
// Repositories — Postgres implementations
const userRepository = new PostgresUserRepository(db);
// Services receive repositories; services never import adapters
const userService = new UserService(userRepository);
// Routes receive services
const app = buildRoutes({ userService });
export { app };This pattern means that a test can pass in a FakeUserRepository — a plain TypeScript object that satisfies the UserRepository interface — without a running database.
Storage adapter (StorageProvider) ​
ADR 0010 and ADR 0024 jointly define the StorageProvider abstraction. The interface has three operations. Clients receive a short-lived signed URL and stream bytes directly from the storage backend; the API never proxies media bytes.
Interface — upload, getSignedUrl, delete ​
typescript
// packages/types/src/providers/storage-provider.ts
// UNVALIDATED
export interface StorageProvider {
/**
* Write an object to the storage backend.
* `key` is a provider-agnostic path (e.g. "sermons/2026/20260601-morning.mp4").
* The key is stored in the database; it is never returned raw to a client.
*/
upload(key: string, data: NodeJS.ReadableStream, contentType: string): Promise<void>;
/**
* Issue a short-lived signed URL for a single object.
* The URL expires after `expiresInSeconds` and is not stored.
* All backends (Azure Blob SAS, S3 presigned, R2 presigned, GCS signed) implement
* the same contract — see ADR 0010.
*/
getSignedUrl(key: string, expiresInSeconds: number): Promise<string>;
/** Remove an object from the storage backend. */
delete(key: string): Promise<void>;
}Cloudflare R2 implementation (S3-compatible SDK) ​
R2 is the anticipated cost-triggered swap (zero egress fees) documented in ADR 0010. It is S3-compatible, so the @aws-sdk/client-s3 package works without modification.
typescript
// apps/api/src/adapters/storage/r2-storage-provider.ts
// UNVALIDATED
import {
DeleteObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import type { StorageProvider } from '@heritage/types';
export class R2StorageProvider implements StorageProvider {
private readonly client: S3Client;
private readonly bucket: string;
constructor(opts: {
accountId: string;
accessKeyId: string;
secretAccessKey: string;
bucket: string;
}) {
this.bucket = opts.bucket;
this.client = new S3Client({
region: 'auto',
endpoint: `https://${opts.accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: opts.accessKeyId,
secretAccessKey: opts.secretAccessKey,
},
});
}
async upload(
key: string,
data: NodeJS.ReadableStream,
contentType: string,
): Promise<void> {
await this.client.send(
new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: data, ContentType: contentType }),
);
}
async getSignedUrl(key: string, expiresInSeconds: number): Promise<string> {
const command = new GetObjectCommand({ Bucket: this.bucket, Key: key });
return getSignedUrl(this.client, command, { expiresIn: expiresInSeconds });
}
async delete(key: string): Promise<void> {
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
}
}Azure Blob implementation (for provider swap) ​
The Azure Blob adapter is the active default. Its signed-URL equivalent is a Shared Access Signature (SAS) URL, which maps directly to the getSignedUrl contract.
typescript
// apps/api/src/adapters/storage/azure-blob-storage-provider.ts
// UNVALIDATED
import {
BlobServiceClient,
generateBlobSASQueryParameters,
BlobSASPermissions,
StorageSharedKeyCredential,
} from '@azure/storage-blob';
import type { StorageProvider } from '@heritage/types';
export class AzureBlobStorageProvider implements StorageProvider {
private readonly blobService: BlobServiceClient;
private readonly credential: StorageSharedKeyCredential;
private readonly container: string;
constructor(opts: {
accountName: string;
accountKey: string;
container: string;
}) {
this.credential = new StorageSharedKeyCredential(opts.accountName, opts.accountKey);
this.blobService = new BlobServiceClient(
`https://${opts.accountName}.blob.core.windows.net`,
this.credential,
);
this.container = opts.container;
}
async upload(key: string, data: NodeJS.ReadableStream, contentType: string): Promise<void> {
const client = this.blobService.getContainerClient(this.container).getBlockBlobClient(key);
await client.uploadStream(data, undefined, undefined, {
blobHTTPHeaders: { blobContentType: contentType },
});
}
async getSignedUrl(key: string, expiresInSeconds: number): Promise<string> {
const startsOn = new Date();
const expiresOn = new Date(startsOn.getTime() + expiresInSeconds * 1000);
const sasParams = generateBlobSASQueryParameters(
{
containerName: this.container,
blobName: key,
permissions: BlobSASPermissions.parse('r'),
startsOn,
expiresOn,
},
this.credential,
);
const accountName = (this.credential as unknown as { accountName: string }).accountName;
return `https://${accountName}.blob.core.windows.net/${this.container}/${key}?${sasParams}`;
}
async delete(key: string): Promise<void> {
await this.blobService
.getContainerClient(this.container)
.getBlockBlobClient(key)
.delete();
}
}Member-only gating via signed URLs ​
The storage adapter never enforces RBAC on its own — it is a dumb I/O wrapper. RBAC is enforced in the API route before the signed URL is issued:
typescript
// apps/api/src/routes/media.ts (excerpt)
// UNVALIDATED
import type { StorageProvider } from '@heritage/types';
// `storage` is injected at startup — the route does not know which backend is active.
export function buildMediaRoutes(storage: StorageProvider) {
return async (req: AuthenticatedRequest, res: Response) => {
// 1. Clerk JWT is already validated by middleware; req.user.role comes from
// the database lookup performed by the auth middleware (ADR 0006).
if (req.user.status !== 'active') {
return res.status(403).json({ error: 'account not approved' });
}
const { key } = req.params;
// 2. Issue a signed URL — never the raw object key.
// Default expiry: 2 hours (7 200 seconds). Adjust per content sensitivity.
const url = await storage.getSignedUrl(key, 7_200);
return res.json({ url });
};
}The signed URL expires automatically. No client ever holds a permanent or public URL (ADR 0010). Switching the STORAGE_PROVIDER environment variable from azure to r2 changes which adapter is injected at startup; the route code above is unaffected.
Notification adapters ​
ADR 0013 defines the multi-channel fan-out transport. The SMS and email channels each sit behind a provider-neutral adapter. Push (Expo → APNs/FCM) uses the Expo SDK directly in its adapter; in-app notifications write to the Postgres notifications table through the repository layer.
SmsProvider interface — send(to, body) ​
typescript
// packages/types/src/providers/sms-provider.ts
// UNVALIDATED
export interface SmsResult {
messageId: string;
status: 'queued' | 'sent' | 'failed';
error?: string;
}
export interface SmsProvider {
/**
* Send a plain-text SMS segment to a verified E.164 phone number.
* Returns a result object; implementations must NOT throw on delivery failure —
* the transport fan-out continues on other channels regardless.
*/
send(to: string, body: string): Promise<SmsResult>;
}Twilio implementation ​
Twilio is the decided SMS provider (ADR 0013). The adapter imports the twilio package only in this file.
typescript
// apps/api/src/adapters/sms/twilio-sms-provider.ts
// UNVALIDATED
import twilio from 'twilio';
import type { SmsProvider, SmsResult } from '@heritage/types';
export class TwilioSmsProvider implements SmsProvider {
private readonly client: twilio.Twilio;
private readonly fromNumber: string;
constructor(opts: { accountSid: string; authToken: string; fromNumber: string }) {
this.client = twilio(opts.accountSid, opts.authToken);
this.fromNumber = opts.fromNumber;
}
async send(to: string, body: string): Promise<SmsResult> {
try {
const message = await this.client.messages.create({
to,
from: this.fromNumber,
body,
});
return { messageId: message.sid, status: 'queued' };
} catch (err) {
// Log to Application Insights in the caller (ADR 0005); return failure, do not throw.
return {
messageId: '',
status: 'failed',
error: err instanceof Error ? err.message : String(err),
};
}
}
}A2P 10DLC note. Twilio credentials are added to Key Vault only after A2P 10DLC (10-digit long code) brand and campaign registration completes with The Campaign Registry. This is a US carrier requirement independent of the provider choice. See ADR 0013 for the activation gate details.
Telnyx is documented in ADR 0013 as the cost-down drop-in (approximately half the per-segment cost at congregation scale). A TelnyxSmsProvider implementing the same SmsProvider interface is all that is needed to swap; no route or service code changes.
EmailProvider interface — send(to, subject, html) ​
typescript
// packages/types/src/providers/email-provider.ts
// UNVALIDATED
export interface EmailResult {
messageId: string;
status: 'accepted' | 'failed';
error?: string;
}
export interface EmailProvider {
/**
* Send a transactional email.
* `from` defaults to the platform's configured sender address if omitted.
* Implementations must NOT throw on delivery failure.
*/
send(opts: {
to: string;
subject: string;
html: string;
from?: string;
}): Promise<EmailResult>;
}SendGrid implementation ​
SendGrid is the decided email provider (ADR 0013, free tier). The @sendgrid/mail import lives only in this adapter file.
typescript
// apps/api/src/adapters/email/sendgrid-email-provider.ts
// UNVALIDATED
import sgMail from '@sendgrid/mail';
import type { EmailProvider, EmailResult } from '@heritage/types';
export class SendGridEmailProvider implements EmailProvider {
private readonly defaultFrom: string;
constructor(opts: { apiKey: string; defaultFrom: string }) {
sgMail.setApiKey(opts.apiKey);
this.defaultFrom = opts.defaultFrom;
}
async send(opts: {
to: string;
subject: string;
html: string;
from?: string;
}): Promise<EmailResult> {
try {
const [response] = await sgMail.send({
to: opts.to,
from: opts.from ?? this.defaultFrom,
subject: opts.subject,
html: opts.html,
});
return {
messageId: response.headers['x-message-id'] as string ?? '',
status: 'accepted',
};
} catch (err) {
return {
messageId: '',
status: 'failed',
error: err instanceof Error ? err.message : String(err),
};
}
}
}Startup wiring — env-var-driven adapter selection ​
All adapter selection happens in one place: the API startup module. Environment variables control which concrete implementation is constructed. Nothing else in the codebase references environment variables for provider selection.
typescript
// apps/api/src/adapters/index.ts
// UNVALIDATED
import { drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import { PostgresUserRepository } from './db/postgres-user-repository.js';
import { AzureBlobStorageProvider } from './storage/azure-blob-storage-provider.js';
import { R2StorageProvider } from './storage/r2-storage-provider.js';
import { TwilioSmsProvider } from './sms/twilio-sms-provider.js';
import { SendGridEmailProvider } from './email/sendgrid-email-provider.js';
import type { StorageProvider } from '@heritage/types';
import type { SmsProvider } from '@heritage/types';
import type { EmailProvider } from '@heritage/types';
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Required environment variable ${name} is not set`);
return value;
}
// --- Database ---------------------------------------------------------------
const pool = new pg.Pool({ connectionString: requireEnv('DATABASE_URL') });
export const db = drizzle(pool);
export const userRepository = new PostgresUserRepository(db);
// Add further repositories here as features are built.
// --- Storage ----------------------------------------------------------------
// STORAGE_PROVIDER=azure (default, active)
// STORAGE_PROVIDER=r2 (swap when Azure egress exceeds the ADR 0010 trigger)
function buildStorageProvider(): StorageProvider {
const provider = process.env['STORAGE_PROVIDER'] ?? 'azure';
if (provider === 'r2') {
return new R2StorageProvider({
accountId: requireEnv('R2_ACCOUNT_ID'),
accessKeyId: requireEnv('R2_ACCESS_KEY_ID'),
secretAccessKey: requireEnv('R2_SECRET_ACCESS_KEY'),
bucket: requireEnv('R2_BUCKET'),
});
}
return new AzureBlobStorageProvider({
accountName: requireEnv('AZURE_STORAGE_ACCOUNT_NAME'),
accountKey: requireEnv('AZURE_STORAGE_ACCOUNT_KEY'),
container: requireEnv('AZURE_STORAGE_CONTAINER'),
});
}
export const storageProvider: StorageProvider = buildStorageProvider();
// --- SMS --------------------------------------------------------------------
// SMS_PROVIDER=twilio (decided — active after A2P 10DLC registration)
// SMS_PROVIDER=telnyx (cost-down drop-in — add TelnyxSmsProvider when needed)
function buildSmsProvider(): SmsProvider {
const provider = process.env['SMS_PROVIDER'] ?? 'twilio';
if (provider === 'twilio') {
return new TwilioSmsProvider({
accountSid: requireEnv('TWILIO_ACCOUNT_SID'),
authToken: requireEnv('TWILIO_AUTH_TOKEN'),
fromNumber: requireEnv('TWILIO_FROM_NUMBER'),
});
}
throw new Error(`Unknown SMS_PROVIDER: ${provider}`);
}
export const smsProvider: SmsProvider = buildSmsProvider();
// --- Email ------------------------------------------------------------------
// EMAIL_PROVIDER=sendgrid (decided — active on free tier)
function buildEmailProvider(): EmailProvider {
const provider = process.env['EMAIL_PROVIDER'] ?? 'sendgrid';
if (provider === 'sendgrid') {
return new SendGridEmailProvider({
apiKey: requireEnv('SENDGRID_API_KEY'),
defaultFrom: requireEnv('EMAIL_DEFAULT_FROM'),
});
}
throw new Error(`Unknown EMAIL_PROVIDER: ${provider}`);
}
export const emailProvider: EmailProvider = buildEmailProvider();The server.ts entry point imports from adapters/index.ts and passes the constructed instances into service constructors. No service, route, or domain module ever imports from adapters/ directly.
Environment variables summary.
| Variable | Used when | Notes |
|---|---|---|
DATABASE_URL | Always | Postgres connection string; injected from Key Vault at runtime (ADR 0004) |
STORAGE_PROVIDER | Always | azure (default) or r2 |
AZURE_STORAGE_ACCOUNT_NAME | STORAGE_PROVIDER=azure | Azure Blob account name |
AZURE_STORAGE_ACCOUNT_KEY | STORAGE_PROVIDER=azure | Azure Blob shared key |
AZURE_STORAGE_CONTAINER | STORAGE_PROVIDER=azure | Container name (e.g. media-sermons) |
R2_ACCOUNT_ID | STORAGE_PROVIDER=r2 | Cloudflare account ID |
R2_ACCESS_KEY_ID | STORAGE_PROVIDER=r2 | R2 API token key ID |
R2_SECRET_ACCESS_KEY | STORAGE_PROVIDER=r2 | R2 API token secret |
R2_BUCKET | STORAGE_PROVIDER=r2 | R2 bucket name |
SMS_PROVIDER | Always | twilio (default) |
TWILIO_ACCOUNT_SID | SMS_PROVIDER=twilio | Twilio account SID |
TWILIO_AUTH_TOKEN | SMS_PROVIDER=twilio | Twilio auth token |
TWILIO_FROM_NUMBER | SMS_PROVIDER=twilio | E.164 sender number (A2P 10DLC registered) |
EMAIL_PROVIDER | Always | sendgrid (default) |
SENDGRID_API_KEY | EMAIL_PROVIDER=sendgrid | SendGrid API key |
EMAIL_DEFAULT_FROM | EMAIL_PROVIDER=sendgrid | From address (e.g. no-reply@heritage.church) |
All secrets are stored in kv-hcs-vault-01 and injected into the container at runtime via Azure Container Apps managed identity (ADR 0004, ADR 0024). No secret is committed to source.
Related ADRs ​
| ADR | Title | Relevance |
|---|---|---|
| ADR 0004 | Cloud/hosting stack, CI/CD, and free-tier path | Key Vault for secrets; Azure Blob Storage as the initial storage backend |
| ADR 0006 | Two-plane RBAC with reconciled role model | RBAC enforcement in routes before any signed URL is issued |
| ADR 0008 | Platform composition | API-first mandate — no direct database or storage access from clients |
| ADR 0010 | Sermons & Music Hub | StorageProvider interface origin; signed-URL delivery model; R2 as cost-triggered swap |
| ADR 0013 | Notification transport | SmsProvider (Twilio/Telnyx) and EmailProvider (SendGrid) decisions; fan-out behavior |
| ADR 0024 | Cloud portability & provider abstraction | Governing rule — SDK imports in adapters only; the four seams; Postgres over Azure SQL |