staffora
Home Features About Architecture Docs API Contact GitHub

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.

1

errorsPlugin

Global error handler. Maps thrown errors to structured HTTP responses with the standard { error: { code, message, details, requestId } } shape.

2

metricsPlugin

Prometheus metrics collection. Exposes a /metrics endpoint for scraping request durations, status codes, and system health.

3

dbPlugin

PostgreSQL connection via postgres.js. Provides tagged template SQL with automatic camelCase column transform.

4

cachePlugin

Redis connection via ioredis. Used for session caching, rate limit counters, idempotency keys, and pub/sub.

5

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.

6

betterAuthPlugin

Better Auth session management with cookie-based authentication, multi-factor authentication support, and session lifecycle hooks.

7

authPlugin

Session resolution with a three-tier cache: in-memory then Redis then database. Also handles API key authentication and CSRF protection.

8

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.

9

rbacPlugin

Provides the requirePermission("resource", "action") guard. Checks the user's role against the permission mapping before allowing access.

10

idempotencyPlugin

Deduplicates mutations via the Idempotency-Key header. Stores keys in both Redis (fast check) and the database (durable record).

11

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

Employee Lifecycle Leave Request Case Management Workflow Performance Review Recruitment Pipeline Flexible Working Data Breach

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. 1

    HTTP Request arrives — Nginx forwards to an available Elysia instance.

  2. 2

    errorsPlugin wraps the handler in a try/catch for structured error responses.

  3. 3

    metricsPlugin starts a timer for request duration tracking.

  4. 4

    dbPlugin provides the SQL connection. cachePlugin provides the Redis connection.

  5. 5

    rateLimitPlugin checks IP against Redis rate limit counters.

  6. 6

    betterAuthPlugin / authPlugin resolves the session via three-tier cache: L1 memory, L2 Redis, L3 database.

  7. 7

    tenantPlugin resolves the tenant and sets RLS context via app.set_tenant_context().

  8. 8

    rbacPlugin checks the user's permissions against the required resource/action pair.

  9. 9

    idempotencyPlugin checks the Idempotency-Key header for duplicate mutation requests.

  10. 10

    Route handler executes — business logic runs. Domain events written to app.domain_outbox in the same transaction.

  11. 11

    auditPlugin logs the operation with old/new values, resource type, and resource ID.

  12. 12

    Response returned to the client with appropriate status code.

  13. 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.

R

Route Handler

routes.ts

HTTP binding, TypeBox validation, auth guards

S

Service Layer

service.ts

Business logic, state machine transitions, validations

D

Repository Layer

repository.ts

Raw SQL with tagged templates, cursor pagination

P

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.

L1

In-Memory Cache

per-process

30-second TTL, max 2,000 entries. Fastest lookup, no network round-trip. Each API instance maintains its own cache.

miss
L2

Redis Cache

shared

30-second TTL. Shared across all API instances. Single network hop to Redis. Populates L1 on hit.

miss
L3

Database Query

source of truth

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.