SA Platform Design¶
Overview¶
SA Platform is a modular, monorepo-based platform that extends the existing clinical data model into a comprehensive suite of independently adoptable services. Products (OV2, AIDA, future apps) install only the modules they need via scoped npm packages and a unified client SDK.
Goals¶
- Unified platform identity under
@sa-platform/*scoped packages - Each module independently adoptable — products pick and choose
- Centralized auth service replaces per-module auth
- Client SDKs (TypeScript + Python) for consuming products, shared server-side libraries for building modules
- Start simple on deployment, split when scale demands it
- Internal-first, designed so it could be opened to external consumers later
Constraints¶
- Consumers are primarily TypeScript/Node, one Python project, React Native mobile apps
- All hosted on AWS (UK + US independent stacks, no cross-region data)
- Must support migrating existing clinical-data-model repo into the new structure without disruption
- GDPR/HIPAA compliance requirements carry forward from clinical data model
Monorepo Structure¶
sa-platform/
├── services/
│ ├── auth/ # Central auth & identity service
│ ├── clinical-api/ # Migrated from current repo
│ ├── user-management/ # User profiles, roles, org membership
│ ├── payments/ # Stripe/payment provider integration, subscriptions, invoicing
│ ├── prescriptions/ # Prescription management, e-prescribing
│ ├── notifications/ # Email, SMS, push — templated, provider-agnostic
│ ├── scheduling/ # Appointments, availability, calendar
│ ├── practitioner-registry/ # Practitioner profiles, credentials, specialties
│ ├── consent/ # Consent types, versions, capture, withdrawal
│ ├── ai-review/ # AI inference orchestration, model routing, result storage
│ └── human-review/ # Review queues, assignments, decisions, escalation
│
├── packages/
│ ├── common/ # @sa-platform/common — shared NestJS building blocks
│ ├── auth-client/ # @sa-platform/auth-client — token validation, guards, scopes
│ ├── sdk-node/ # @sa-platform/sdk — TypeScript client SDK
│ ├── sdk-python/ # sa-platform (PyPI) — Python client SDK
│ ├── tsconfig/ # @sa-platform/tsconfig — shared TS config
│ └── eslint-config/ # @sa-platform/eslint-config — shared lint rules
│
├── infrastructure/ # Terraform modules
├── docs/
├── docker-compose.yml # Local dev: MySQL, Redis, MinIO, all services
├── pnpm-workspace.yaml
├── turbo.json # Turborepo build orchestration
└── package.json
Key structural decisions¶
- Consent is a standalone service, not embedded in clinical-api — payments, prescriptions, and other modules also need consent
- AI Review and Human Review are separate services — different scaling profiles (AI is bursty/compute-heavy, human review is queue-based)
- Each service owns its own Prisma schema and database — no shared databases between services
- Services communicate via REST (synchronous) and domain events (asynchronous)
Shared Packages¶
@sa-platform/common¶
Shared server-side NestJS modules extracted from clinical-api's src/common/. Services import only what they need.
| Module | Responsibility |
|---|---|
| CryptoModule | AES-256-GCM field encryption, DEK management, KMS integration |
| HashModule | Deterministic SHA-256 hashing with per-field salts |
| IdModule | UUID v7 generation |
| StorageModule | S3/MinIO presigned URLs, upload/download |
| RedisModule | ioredis wrapper, idempotency keys, pub/sub |
| EventModule | Domain event emission, webhook delivery, HMAC signing |
| AuditModule | Audit log creation with actor snapshots |
| TenancyModule | Prisma tenant-scoping middleware, org isolation |
| CorrelationModule | X-Correlation-Id middleware |
| ProblemJsonFilter | RFC 7807 error responses |
| RequestContext | AsyncLocalStorage for per-request state |
| BaseDto helpers | Pagination, cursor-based listing, common validation decorators |
@sa-platform/auth-client¶
Lightweight library for token validation. Every service installs this. Separate from common because it has a single external dependency (auth service JWKS endpoint) and changes on a different cadence.
- AuthGuard — validates JWTs issued by the auth service (JWKS-based)
- ScopesGuard +
@RequireScopes()— scope enforcement - ActorContextMiddleware — parses end-user identity from
X-Actor-Context @Public()decorator — exempts health/status endpoints
@sa-platform/tsconfig, @sa-platform/eslint-config¶
Shared build and lint configuration. All services extend these.
Central Auth Service¶
Responsibilities¶
| Concern | Detail |
|---|---|
| API client credentials | Client ID + Argon2id-hashed secrets, rotation with grace periods |
| Token issuance | POST /v1/oauth/token — issues JWTs with scopes, org context, expiry |
| JWKS endpoint | GET /.well-known/jwks.json — public keys for token verification |
| Scope registry | Available scopes per module (e.g. clinical:patients:read) |
| Product registration | Products, their auth methods, and allowed scopes |
| API client management | CRUD for API clients, scope assignment, secret rotation |
Not owned by auth¶
- End-user authentication (login, MFA, password reset) — that's User Management, potentially backed by Auth0/Cognito
- Authorization policies (RBAC/ABAC) — services enforce domain-specific rules using scopes and actor context
- Session management — stateless JWT, no server-side sessions
Scope naming convention¶
{module}:{resource}:{action}
clinical:patients:read
clinical:patients:write
clinical:cases:read
payments:invoices:read
payments:invoices:write
scheduling:appointments:read
notifications:send
Products are assigned a subset of scopes. The auth service enforces that tokens only contain entitled scopes.
Token format¶
{
"sub": "client_abc123",
"iss": "https://auth.sa-platform.com",
"aud": "sa-platform",
"org_id": "org_xyz",
"product_id": "prod_ov2",
"scopes": ["clinical:patients:read", "clinical:cases:write"],
"exp": 1713500400,
"iat": 1713499500
}
Inter-Service Communication¶
Synchronous: REST¶
Services call each other through internal typed clients using service-to-service OAuth2 credentials.
- Service discovery via environment variables (dev/staging), AWS Cloud Map or ALB routing (production)
- 5s default timeout, 2 retries with exponential backoff
Asynchronous: Domain Events¶
Events carry references, not data. PHI never appears in events. Consumers call back to the owning service for details.
Event envelope:
{
"id": "evt_01J...",
"type": "clinical.patient.created",
"org_id": "org_xyz",
"resource_id": "pat_abc",
"actor": { "client_id": "...", "user_id": "..." },
"timestamp": "2026-04-19T10:00:00Z",
"correlation_id": "corr_..."
}
Transport:
- Phase 1: Redis Pub/Sub (current pattern, works at moderate scale)
- Phase 2: AWS EventBridge or SQS/SNS for durability and dead-letter queues
Sync vs. async¶
| Pattern | When | Example |
|---|---|---|
| Sync (REST) | Caller needs result to continue | Fetch patient details for a notification |
| Async (Events) | Fire-and-forget, fan-out, eventual consistency | Case closed triggers AI review |
No shared databases¶
Each service owns its schema. Cross-service data access is via REST calls only.
Deployment & Infrastructure¶
Local Development¶
Single docker-compose.yml boots shared infrastructure (MySQL, Redis, MinIO) plus services. Developers start only what they need:
docker compose up auth clinical-api redis mysql
Each service has its own .env and Prisma schema pointing at its own database within the shared MySQL instance.
Production (AWS)¶
Phase 1 — Shared cluster, separate services:
- Single Aurora MySQL cluster, separate database per service (logical isolation)
- Single ECS cluster, separate Fargate task definitions per service
- Shared ALB with path-based routing
- Shared ElastiCache Redis
- Single VPC with private/public subnets
- Per-region independence (UK + US)
Phase 2 — Split when needed:
- Services with divergent scaling needs get their own Aurora cluster
- High-throughput services get dedicated Redis
- Independent ALB + health checks for different SLA requirements
Split trigger: Traffic patterns diverge, independent maintenance windows needed, or team wants autonomous deploy cadence.
CI/CD¶
Turborepo handles the build graph — only affected packages/services build and test:
- Lint + typecheck (whole repo, cached)
- Unit tests (affected services)
- Integration tests (affected services, Testcontainers)
- Build (affected services)
- Deploy (per-service, environment-gated: dev → staging → production)
SDK Design¶
Node SDK (@sa-platform/sdk)¶
Auto-generated from OpenAPI specs with a hand-written ergonomic layer.
import { SAPlatform } from '@sa-platform/sdk';
const sa = new SAPlatform({
baseUrl: 'https://api.sa-platform.com',
clientId: process.env.SA_CLIENT_ID,
clientSecret: process.env.SA_CLIENT_SECRET,
});
await sa.clinical.patients.create({ ... });
await sa.payments.invoices.list({ status: 'unpaid' });
// Per-module import for tree-shaking
import { ClinicalClient } from '@sa-platform/sdk/clinical';
Built-in behaviors:
- OAuth2 token acquisition and caching, auto-refresh before expiry
- Exponential backoff retries on 429/5xx
- Correlation ID generation and propagation
- Async iterators for paginated list endpoints
- Problem+JSON mapped to typed error classes
- Optional structured logger hook, no console output by default
Versioning: SDK version tracks independently. Each module client targets a specific API version. Both /v1/ and /v2/ supported during migration.
Python SDK (sa-platform)¶
Auto-generated from same OpenAPI specs. Same namespacing (sa.clinical.patients.create(...)). Async via httpx. Published to private PyPI.
Mobile¶
React Native uses the Node SDK directly. Native iOS/Android clients can be generated from OpenAPI specs if needed.
SDK release process¶
Service deploys → OpenAPI spec updated → CI generates SDK → version bumped → published
Automated with manual approval gate for breaking changes.
Migration Path¶
Step 1: Create sa-platform repo¶
New repo with pnpm workspace, Turborepo, shared tsconfig/eslint, docker-compose, CI pipeline.
Step 2: Move clinical-api¶
Copy services/clinical-api/ into new repo. Update imports to shared configs. Verify all tests pass.
Step 3: Extract shared packages¶
Pull src/common/ into packages/common/. Pull auth middleware into packages/auth-client/. Clinical-api imports as workspace dependencies ("@sa-platform/common": "workspace:*").
Step 4: Build auth service¶
New services/auth/ with own Prisma schema. Migrate Organisation, Product, ApiClient models. Auth service exposes OAuth2 + JWKS. Clinical-api switches to @sa-platform/auth-client. Dual-validation transition window for old and new tokens.
Step 5: Build SDK¶
packages/sdk-node/ generates typed clients from OpenAPI specs. Publish as @sa-platform/sdk.
Step 6: Add modules incrementally¶
New services follow established pattern: own Prisma schema, import from common + auth-client, publish OpenAPI spec for SDK generation.
Build order¶
1. Auth ← everything depends on this
2. User Management ← auth + user together unlock identity
3. Consent ← cross-cutting, needed by clinical, payments, etc.
4. Notifications ← most services want to send notifications
5. Everything else ← driven by product roadmap
Consent migration note¶
Clinical-api currently owns ConsentType, ConsentTextVersion, and ConsentRecord models. When the standalone consent service is built (Step 3 in the build order), these models migrate from clinical-api's Prisma schema to the consent service's schema. During transition, clinical-api calls the consent service via REST instead of querying its own database. The consent service becomes the single owner of all consent data across the platform.
Original repo¶
Archive clinical-data-model after migration is verified.