Solution Design Baseline — Ghasi EHR Platform
Scope: System-level solution design patterns that ALL modules MUST follow.
Authority: Normative. Module solution designs extend but never contradict this baseline.
1. Layered Architecture (Per Service)
Every NestJS microservice MUST follow this internal layering:
Controller Layer → HTTP/FHIR request handling, DTO validation, auth guards
↓
Application Layer → Use cases / orchestration, event publishing, cross-concern coordination
↓
Domain Layer → Entities, aggregates, value objects, business rules, domain events
↓
Infrastructure → TypeORM repositories, NATS publishers, Redis cache, external adapters
Rules
- Controllers MUST NOT contain business logic — delegate to application/domain services.
- Domain entities MUST NOT depend on infrastructure (no TypeORM decorators in domain models if using clean architecture; or colocate if pragmatic but isolate business rules in methods).
- Application services orchestrate: validate → execute domain logic → persist → publish events → return.
2. DTO & Validation
- Use
class-validatorwith strict DTOs for all REST endpoints. - Return
400 VALIDATION_FAILEDwith field-level details. - FHIR writes validate against base resource rules + declared profile constraints.
- Validation errors MUST be machine-readable with stable error codes.
3. Authentication & Authorization Flow
Client → Kong (JWT validation) → Service Guard (re-validate JWT, extract tenantId/userId)
→ ModuleEntitlementGuard (check licensing)
→ RolesGuard + @Roles() decorator (RBAC)
→ ABAC check via Access Policy Service (for patient-linked resources)
→ Controller → Service
Critical Rules
tenantIdandactorIdMUST be extracted from JWT claims — NEVER from request body/query params.@Roles()decorators MUST be applied on every endpoint.RolesGuardwithout@Roles()is a no-op bug.- ABAC evaluation via
POST /internal/access/evaluatereturns{ effect, reason, policyId }. - Patient portal uses a separate OAuth client/realm with restricted scopes.
4. Event Publishing Pattern
// After successful persistence
await this.repository.save(entity);
await this.natsClient.publish(
CloudEventsBuilder.build({
source: 'ghasi/{service-name}',
type: 'ghasi.{domain}.{entity}.{action}',
tenantid: ctx.tenantId,
actorid: ctx.actorId,
correlationid: ctx.correlationId,
data: { /* minimal event payload */ },
})
);
Rules
- Publish AFTER successful persistence (not before).
- Event payloads carry minimal identifiers — consumers fetch full data if needed.
- Use
@ghasi/event-schemasZod schemas for payload validation. - NATS subjects MUST be lowercase dot-delimited.
5. Database Patterns
5.1 Schema Conventions
CREATE TABLE {entity} (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- domain columns --
version INTEGER NOT NULL DEFAULT 1, -- optimistic locking
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_{entity}_tenant ON {entity}(tenant_id);
5.2 Rules
- Migrations only — never
synchronize: true. - Tenant-scoped indexes on every query path.
- Soft-delete where retention policy requires; hard-delete only with explicit policy.
payload_json(JSONB) for FHIR resource representation alongside indexed columns.
6. FHIR exposure pattern
Each service that owns clinical facts:
- Stores data in its own PostgreSQL schema (relational + optional
payload_json). - Exposes an internal FHIR adapter interface that
fhir-gatewaycalls. - The adapter translates between internal entities and FHIR R4 JSON.
- Gateway handles: routing, tenant isolation, ABAC enforcement,
OperationOutcomeformatting.
Client → Kong → fhir-gateway → {service}.fhir-adapter → {service} DB
7. Integration / Adapter Pattern
External integrations (HL7 v2, DICOM, X12, national registries):
External System
↕ (MLLP / DICOMweb / HTTPS / SFTP)
Adapter Service (e.g., hl7v2-interop)
→ Parse + validate + deduplicate
→ Map to canonical FHIR
→ Call fhir-gateway or domain service
→ Publish interop.* events
→ Store immutable message log
Rules
- Adapters MUST preserve external message IDs for deduplication.
- Adapters MUST NOT introduce competing clinical data models.
- Retry with exponential backoff; DLQ for failed processing.
8. Error Handling
REST Error Envelope
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable description",
"details": {}
},
"correlationId": "uuid",
"timestamp": "ISO-8601"
}
Standard Error Codes
| Code | HTTP | Meaning |
|---|---|---|
VALIDATION_FAILED | 400 | DTO/input validation failure |
UNAUTHORIZED | 401 | Missing/invalid JWT |
FORBIDDEN | 403 | RBAC/ABAC denied |
NOT_FOUND | 404 | Resource not found in tenant scope |
CONFLICT | 409 | Optimistic lock / duplicate |
MODULE_NOT_ACTIVE | 422 | Module not licensed for this node |
RATE_LIMITED | 429 | Rate limit exceeded |
FHIR Errors
- Always return
OperationOutcomewithissue[].codeandissue[].diagnostics.
9. Caching Strategy
- Authorization-aware caching — cache keys include tenant + user/role context.
- Redis 7 for: entitlement cache (≤30s TTL), FHIR conformance artifacts, search typeaheads.
- Cache invalidation via NATS events where applicable.
10. Async Processing
Use async workflows for:
- Integration message ingestion and dispatch
- Notification delivery (SMS, email, push)
- PDF/export rendering
- Batch operations (bulk data export)
Pattern: domain event → NATS → worker consumer → process → emit completion event.
11. Folder Structure Convention (Per Service)
{service-name}/
src/
app.module.ts
{domain}/
{domain}.module.ts
{domain}.controller.ts # REST endpoints
{domain}.service.ts # Application/orchestration
dto/
create-{entity}.dto.ts
update-{entity}.dto.ts
{entity}-response.dto.ts
entities/
{entity}.entity.ts # TypeORM entity
events/
{domain}.events.ts # Event type constants + payloads
guards/ # Module-specific guards if needed
fhir/ # FHIR adapter
{resource}.adapter.ts
common/
filters/
interceptors/
decorators/
config/
test/
unit/
integration/
e2e/
migrations/