Architecture
System Architecture
A deep dive into Staffora's architecture — multi-tenant isolation, plugin-based middleware, state machine governance, and production infrastructure.
System Overview
The high-level topology of a Staffora deployment, from client to database.
graph TB
Client["React Frontend<br/>React Router v7 + TanStack Query"]
LB["Nginx Load Balancer"]
API1["Elysia.js API<br/>Instance 1"]
API2["Elysia.js API<br/>Instance 2"]
Worker["Background Worker<br/>Redis Streams Consumer"]
PGB["PgBouncer<br/>Connection Pooler"]
PG[("PostgreSQL 16<br/>Row-Level Security")]
Redis[("Redis 7<br/>Cache + Queues")]
S3["AWS S3<br/>File Storage"]
SMTP["SMTP<br/>Email"]
Client --> LB
LB --> API1
LB --> API2
API1 --> PGB
API2 --> PGB
API1 --> Redis
API2 --> Redis
PGB --> PG
Worker --> PG
Worker --> Redis
API1 --> S3
API2 --> S3
Worker --> SMTP
Staffora uses a monorepo architecture with three packages:
api (Elysia.js backend),
web (React frontend), and
shared (types, state machines, utilities).
The backend is horizontally scalable behind Nginx, with PgBouncer for connection pooling and Redis for caching, rate limiting, and job queues.
Plugin-Based Middleware
Every request flows through a deterministic chain of 11 plugins registered in packages/api/src/app.ts.
errorsPlugin
Global error handler. Maps thrown errors to structured HTTP responses with the standard { error: { code, message, details, requestId } } shape.
metricsPlugin
Prometheus metrics collection. Exposes a /metrics endpoint for scraping request durations, status codes, and system health.
dbPlugin
PostgreSQL connection via postgres.js. Provides tagged template SQL with automatic camelCase column transform.
cachePlugin
Redis connection via ioredis. Used for session caching, rate limit counters, idempotency keys, and pub/sub.
rateLimitPlugin
Per-IP rate limiting backed by Redis. Configurable window and max request count. Designed to fail open so that Redis downtime does not block traffic.
betterAuthPlugin
Better Auth session management with cookie-based authentication, multi-factor authentication support, and session lifecycle hooks.
authPlugin
Session resolution with a three-tier cache: in-memory then Redis then database. Also handles API key authentication and CSRF protection.
tenantPlugin
Resolves the tenant from the authenticated session and sets the PostgreSQL RLS context via app.set_tenant_context(). Every subsequent query is scoped to that tenant.
rbacPlugin
Provides the requirePermission("resource", "action") guard. Checks the user's role against the permission mapping before allowing access.
idempotencyPlugin
Deduplicates mutations via the Idempotency-Key header. Stores keys in both Redis (fast check) and the database (durable record).
auditPlugin
Captures old and new values, resource type, and resource ID for every mutation. Provides a full audit trail for compliance.
Source: packages/api/src/app.ts, packages/api/src/plugins/
Multi-Tenant Architecture
Row-Level Security ensures complete data isolation at the database level, regardless of application code correctness.
graph LR
Request["HTTP Request"] --> AuthPlugin["authPlugin<br/>Resolve Session"]
AuthPlugin --> TenantPlugin["tenantPlugin<br/>Resolve Tenant"]
TenantPlugin --> |"SET app.current_tenant_id"| PostgreSQL["PostgreSQL"]
PostgreSQL --> |"RLS Policy Check"| Data["Tenant Data"]
subgraph "Row-Level Security"
Policy["WHERE tenant_id = current_setting('app.current_tenant_id')"]
end
Two DB Users
hris (superuser, migrations only) and hris_app (NOBYPASSRLS, runtime). The application never connects as a superuser.
RLS on Every Table
All tables in the app schema have RLS policies enforcing tenant isolation. No query can access data outside its tenant boundary.
Tenant Context Per Transaction
app.set_tenant_context() sets a PostgreSQL session variable at the start of each request, scoping every query in the transaction.
Zero Cross-Tenant Data Leakage
Even if application code contains bugs, RLS prevents data access across tenants. The isolation guarantee lives at the database layer, not the application layer.
State Machine Governance
Business entities are governed by explicit state machines defined in packages/shared/src/state-machines/. Only valid transitions are allowed.
Employee Lifecycle
stateDiagram-v2
[*] --> pending: Hire
pending --> active: Activate
active --> on_leave: Start Leave
on_leave --> active: Return
active --> terminated: Terminate
terminated --> active: Rehire
Leave Request
stateDiagram-v2
[*] --> draft: Create
draft --> pending: Submit
pending --> approved: Approve
pending --> rejected: Reject
draft --> cancelled: Cancel
pending --> cancelled: Cancel
Performance Review
stateDiagram-v2
[*] --> draft: Create
draft --> in_progress: Launch
in_progress --> calibration: Submit
calibration --> complete: Finalize
All Implemented State Machines
Source: packages/shared/src/state-machines/
Request Lifecycle
Every HTTP request passes through the full plugin chain before reaching the route handler. Here is the complete flow.
- 1
HTTP Request arrives — Nginx forwards to an available Elysia instance.
- 2
errorsPlugin wraps the handler in a try/catch for structured error responses.
- 3
metricsPlugin starts a timer for request duration tracking.
- 4
dbPlugin provides the SQL connection. cachePlugin provides the Redis connection.
- 5
rateLimitPlugin checks IP against Redis rate limit counters.
- 6
betterAuthPlugin / authPlugin resolves the session via three-tier cache: L1 memory, L2 Redis, L3 database.
- 7
tenantPlugin resolves the tenant and sets RLS context via
app.set_tenant_context(). - 8
rbacPlugin checks the user's permissions against the required resource/action pair.
- 9
idempotencyPlugin checks the Idempotency-Key header for duplicate mutation requests.
- 10
Route handler executes — business logic runs. Domain events written to
app.domain_outboxin the same transaction. - 11
auditPlugin logs the operation with old/new values, resource type, and resource ID.
- 12
Response returned to the client with appropriate status code.
- 13
Background worker picks up outbox events via Redis Streams for async processing (notifications, PDF generation, exports).
Module Architecture
Every feature module follows the same layered architecture pattern. 109 modules use this exact structure.
Route Handler
routes.ts HTTP binding, TypeBox validation, auth guards
Service Layer
service.ts Business logic, state machine transitions, validations
Repository Layer
repository.ts Raw SQL with tagged templates, cursor pagination
PostgreSQL
via postgres.js Connection pooled through PgBouncer, RLS-enforced
No ORM
Raw SQL with tagged templates for full control and performance. No Prisma, Drizzle, or other ORM abstraction. Every query is explicit and auditable.
TypeBox Schemas
Request and response validation at the route level using TypeBox. Provides compile-time type inference and runtime JSON Schema validation in one definition.
Modular Architecture
All feature modules follow this exact pattern. Consistent structure means any developer can navigate any module immediately.
Shared Package
The packages/shared package provides TypeScript types, state machines, and utility functions shared between the API and web packages.
Three-Tier Session Cache
Session lookups cascade through three cache layers to minimize database pressure while keeping sessions fresh.
In-Memory Cache
30-second TTL, max 2,000 entries. Fastest lookup, no network round-trip. Each API instance maintains its own cache.
Redis Cache
30-second TTL. Shared across all API instances. Single network hop to Redis. Populates L1 on hit.
Database Query
Better Auth session table. Full database query. Populates both L2 (Redis) and L1 (memory) on hit.
Background Workers
The transactional outbox pattern ensures domain events are never lost, even if the worker is temporarily down.
graph LR
API["API Handler"] --> |"atomic write"| TX[("Transaction")]
TX --> |"1. Business data"| Tables["Tables"]
TX --> |"2. Domain event"| Outbox["domain_outbox"]
Worker["Outbox Worker"] --> |"poll via Redis Streams"| Outbox
Worker --> NotifWorker["Notification<br/>Worker"]
Worker --> PDFWorker["PDF Generation<br/>Worker"]
Worker --> ExportWorker["Export<br/>Worker"]
Worker --> AnalyticsWorker["Analytics<br/>Worker"]
Transactional Guarantee
Domain events are written to the domain_outbox table in the same transaction as the business data. Either both commit or neither does.
Redis Streams
The outbox worker polls for new events via Redis Streams, then dispatches to specialized workers for notifications, PDF generation, data exports, and analytics.
Observability
Full-stack observability with structured logging, distributed tracing, metrics, and audit trails.
Structured Logging
Pino logger with JSON output. Every log entry includes request ID, tenant ID, and user context for correlation.
Distributed Tracing
OpenTelemetry with OTLP exporter. Traces span from the load balancer through the API to the database for full request visibility.
Metrics
Prometheus endpoint at /metrics. Tracks request duration, status codes, active connections, and custom business metrics.
Dashboards
Grafana dashboards included in the Docker Compose observability profile. Pre-configured panels for API performance and system health.
Log Aggregation
Loki + Promtail for centralized log aggregation. Included as a Docker Compose profile for local and staging environments.
Audit Trail
Every mutation is logged with old/new values, resource type, resource ID, user, and timestamp. Required for HR compliance and GDPR.