Files
tessera-ctl/.planning/research/ARCHITECTURE.md
T
schalli 16c2d5b6af docs: complete project research for Tessera portal platform
Synthesize stack, features, architecture, and pitfalls research into
unified summary with roadmap implications and phase suggestions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 08:28:58 +02:00

16 KiB

Architecture Patterns

Domain: Modular portal platform with marketplace, multi-tenancy, configurable dashboard, and desktop wrapper Researched: 2026-06-18

Tessera follows a modular monolith pattern: a single deployable backend that internally separates concerns into distinct modules, fronted by a micro-frontend-capable shell. This avoids premature microservice complexity while maintaining clean module boundaries for future extraction.

+-------------------------------------------------------------------+
|                     Desktop Wrapper (Tauri)                        |
+-------------------------------------------------------------------+
|                     Frontend Shell (React SPA)                     |
|  +------------+  +------------+  +------------+  +-------------+  |
|  | Sidebar    |  | Dashboard  |  | Marketplace|  | Module UI   |  |
|  | Navigation |  | (Widgets)  |  | Browser    |  | (lazy-load) |  |
|  +------------+  +------------+  +------------+  +-------------+  |
+-------------------------------------------------------------------+
|                     API Gateway (Traefik / Nginx)                  |
+-------------------------------------------------------------------+
|                     Backend (Node.js / Fastify)                    |
|  +--------+  +--------+  +--------+  +---------+  +----------+   |
|  | Auth   |  | Tenant |  | Module |  | Market- |  | Dashboard|   |
|  | Module |  | Module |  | Loader |  | place   |  | Service  |   |
|  +--------+  +--------+  +--------+  +---------+  +----------+   |
+-------------------------------------------------------------------+
|                     PostgreSQL (shared, RLS)                       |
+-------------------------------------------------------------------+
|                     Docker Compose (orchestration)                 |
+-------------------------------------------------------------------+

Component Boundaries

Component Responsibility Communicates With
Frontend Shell Application frame (header, sidebar, routing), theme, i18n Backend API via REST/WebSocket
Dashboard Engine Widget grid, drag-and-drop, layout persistence Backend Dashboard Service for saving layouts
Marketplace UI Browse modules, view details, request activation Backend Marketplace Service
Module UI Slots Lazy-loaded UI for activated modules Module-specific backend endpoints
API Gateway Reverse proxy, rate limiting, tenant header injection All backend services
Auth Module Login, session/JWT, LDAP integration, user management PostgreSQL, LDAP server
Tenant Module Tenant CRUD, tenant context resolution, tenant-specific config PostgreSQL, injected into every request
Module Loader Plugin lifecycle (discover, validate, activate, deactivate) Filesystem/registry, PostgreSQL
Marketplace Service Module catalog, licensing, activation per tenant PostgreSQL, Module Loader
Dashboard Service Widget registry, layout CRUD per user per tenant PostgreSQL
PostgreSQL Persistent storage, row-level security for tenant isolation All backend modules
Desktop Wrapper (Tauri) Native window, system tray, local shortcuts Frontend Shell (wraps the web app)

Data Flow

Request flow (authenticated):

User Action
  -> Desktop Wrapper / Browser
    -> Frontend Shell (React Router)
      -> HTTP Request with JWT + Tenant-ID header
        -> API Gateway (validates JWT, injects tenant context)
          -> Backend Route Handler
            -> Service Layer (business logic)
              -> PostgreSQL (RLS enforces tenant isolation)
            <- Response
          <- JSON Response
        <- Frontend renders

Module activation flow:

Admin browses Marketplace
  -> Selects module, clicks "Activate"
    -> POST /api/marketplace/modules/:id/activate
      -> Marketplace Service checks license entitlement
        -> Module Loader registers module for tenant
          -> INSERT module_activations (tenant_id, module_id, status)
        -> Frontend sidebar updates (module appears)
      <- Success response

Dashboard widget flow:

User opens Dashboard
  -> GET /api/dashboard/layout
    -> Returns user's widget layout (positions, sizes)
  -> Frontend renders react-grid-layout with widget components
  -> User drags/resizes widget
    -> PUT /api/dashboard/layout (debounced save)
      -> Persists to PostgreSQL

Core Architecture Decisions

1. Modular Monolith over Microservices

Why: Tessera is built by a single developer (with Claude). Microservices add deployment, debugging, and network complexity that provides zero benefit at this scale. A modular monolith gives clean separation with a single deployment unit.

Structure: Each domain (auth, tenant, marketplace, dashboard, modules) lives in its own directory with its own routes, services, and repository files. They communicate through in-process function calls, not HTTP.

Future path: If a module becomes a bottleneck, extract it to a separate service behind the API gateway. The clean boundaries make this straightforward.

2. Shared Database with Row-Level Security (RLS)

Why: Separate databases per tenant adds massive operational overhead. PostgreSQL RLS enforces tenant isolation at the database level, meaning even application bugs cannot leak data across tenants.

Implementation:

-- Every tenant-scoped table has a tenant_id column
ALTER TABLE modules ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON modules
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

The backend sets app.current_tenant on each database connection based on the authenticated user's tenant. RLS handles the rest transparently.

3. Plugin/Module System as Data-Driven Registry

Why: Modules should not require restarting the server to be discovered. A database-driven registry with filesystem-based module code gives hot-activation without runtime code loading risks.

How it works:

  • Module metadata (name, version, category, routes, permissions) stored in modules table
  • Module code lives in src/modules/<module-name>/ with a standard interface
  • Activation is per-tenant: module_activations table links tenant to module
  • Frontend lazy-loads module UI bundles only when the module is active for the current tenant
  • Backend routes for a module are only registered/accessible when the module is active

4. Frontend Shell with Lazy Module Loading

Why: Loading all module UIs upfront wastes bandwidth and exposes code for modules the tenant has not licensed. Lazy loading (React.lazy + dynamic import) loads module UIs on demand.

Pattern:

// Module registry maps module_id -> lazy component
const moduleRegistry: Record<string, () => Promise<{ default: ComponentType }>> = {
  'domaincheck': () => import('./modules/domaincheck/DomaincheckPage'),
  'email-tools': () => import('./modules/email-tools/EmailToolsPage'),
};

The shell only renders module routes that are in the tenant's active module list (fetched from the backend on login).

5. Tauri over Electron for Desktop Wrapper

Why: Tauri produces ~10MB binaries vs Electron's 100MB+. RAM usage is 20-40MB vs 200-400MB. Tauri uses the system WebView (no bundled Chromium), has a Rust backend for native features, and has a smaller attack surface. The non-programmer maintainer benefits from the simpler, lighter deployment.

Architecture: The Tauri wrapper is a thin shell. It loads the same web application served locally or from the server. Native features (system tray, auto-update, window management) are exposed through Tauri commands.

Patterns to Follow

Pattern 1: Tenant Context Middleware

What: A middleware that extracts tenant identity from the JWT/session, sets it on the request context, and configures the database connection with RLS.

When: Every authenticated request.

// middleware/tenantContext.ts
async function tenantContext(req: FastifyRequest, reply: FastifyReply) {
  const tenantId = req.user.tenantId;
  if (!tenantId) return reply.code(403).send({ error: 'No tenant context' });
  
  // Set RLS context on the database connection
  await req.db.query(`SET app.current_tenant = '${tenantId}'`);
  req.tenantId = tenantId;
}

Pattern 2: Module Interface Contract

What: Every module (backend) exports a standard interface so the platform can discover, mount, and manage it uniformly.

When: Building any new module.

// modules/<name>/index.ts
export interface TesseraModule {
  id: string;
  version: string;
  category: string;
  routes: (app: FastifyInstance) => void;
  widgets?: WidgetDefinition[];    // Optional dashboard widgets
  permissions?: string[];           // Required permissions
  onActivate?: (tenantId: string) => Promise<void>;
  onDeactivate?: (tenantId: string) => Promise<void>;
}

Pattern 3: Widget Component Contract

What: Dashboard widgets follow a standard interface for the grid layout system.

When: Building any dashboard widget (core or module-provided).

// widgets/types.ts
export interface WidgetDefinition {
  id: string;
  name: string;                    // i18n key
  defaultSize: { w: number; h: number };
  minSize?: { w: number; h: number };
  component: () => Promise<{ default: ComponentType<WidgetProps> }>;
  configSchema?: JSONSchema;       // Optional widget settings
}

export interface WidgetProps {
  config: Record<string, unknown>;
  tenantId: string;
  userId: string;
}

Pattern 4: Feature Flag via Module Activation

What: Instead of traditional feature flags, Tessera uses module activation as the feature gating mechanism. If a module is not activated for a tenant, its routes return 403, its UI does not load, and its sidebar entry is hidden.

When: Controlling feature access per tenant.

Anti-Patterns to Avoid

Anti-Pattern 1: Module-to-Module Direct Dependencies

What: Module A directly imports and calls Module B's internal functions.

Why bad: Creates coupling that makes modules impossible to activate independently. If Module B is deactivated, Module A breaks.

Instead: Use an event bus or shared service layer. Modules publish events; other modules subscribe. If the publisher is missing, subscribers simply receive no events.

Anti-Pattern 2: Tenant ID in Application Logic

What: Manually filtering by tenant_id in every query throughout the codebase.

Why bad: A single missed filter leaks data across tenants. Hundreds of places to maintain.

Instead: Use PostgreSQL RLS. Set tenant context once per request at the middleware level. All queries are automatically filtered. Defense in depth: the application layer still passes tenant_id, but RLS is the safety net.

Anti-Pattern 3: Monolithic Frontend Bundle

What: Bundling all module UIs into a single JavaScript bundle.

Why bad: Users download code for modules they cannot access. Bundle size grows linearly with module count. Exposes unlicensed module code.

Instead: Code-split per module. Use dynamic imports. Only load module bundles when the user navigates to an active module.

Anti-Pattern 4: Per-Tenant Database/Schema

What: Creating a separate PostgreSQL database or schema for each tenant.

Why bad: Operational nightmare at scale — migrations must run N times, connection pooling is per-tenant, monitoring multiplies. Overkill for a portal where tenants share the same data model.

Instead: Shared schema with RLS. Single migration path. Single connection pool. Isolation enforced at row level.

Scalability Considerations

Concern At 10 users (internal) At 100 tenants At 1000+ tenants
Database Single PostgreSQL, no pooler needed PgBouncer for connection pooling Read replicas, consider partitioning large tables by tenant_id
Backend Single container Horizontal scaling behind gateway (2-4 replicas) Auto-scaling, consider extracting hot modules to separate services
Frontend Single static bundle CDN for static assets CDN + edge caching, consider module federation for team-developed modules
Module isolation Shared process, trust all modules Same, but add resource limits per module route Consider containerized module backends for untrusted/third-party modules
File storage Local volume S3-compatible object storage Same + lifecycle policies

Suggested Build Order

Based on component dependencies, the recommended build order is:

Phase 1: Foundation
  ├── Docker Compose setup (PostgreSQL + backend + frontend containers)
  ├── PostgreSQL schema with tenant_id columns + RLS policies
  ├── Backend skeleton (Fastify + request lifecycle)
  └── Frontend shell (React + routing + layout frame)

Phase 2: Authentication & Tenancy
  ├── Auth module (login, JWT, session management)
  ├── Tenant middleware (context injection, RLS activation)
  ├── User management (CRUD, roles)
  └── LDAP integration

Phase 3: Module System
  ├── Module interface contract
  ├── Module registry (database-driven)
  ├── Module loader (route mounting, activation/deactivation)
  └── First example module (Domaincheck)

Phase 4: Marketplace & Dashboard
  ├── Marketplace service (catalog, categories, licensing)
  ├── Marketplace UI (browse, activate, manage)
  ├── Dashboard service (layout persistence)
  └── Dashboard UI (react-grid-layout, core widgets)

Phase 5: Polish & Desktop
  ├── i18n (DE + EN)
  ├── Light/Dark theme
  ├── Tauri desktop wrapper
  └── Gitea CI/CD integration

Dependency rationale:

  • Phase 1 first because everything depends on the database, backend framework, and frontend shell
  • Phase 2 before modules because module activation requires knowing WHO is asking and WHICH tenant they belong to
  • Phase 3 before marketplace because the marketplace manages modules — the module system must exist first
  • Phase 4 can partially parallelize (dashboard is independent of marketplace) but both need the module system
  • Phase 5 is pure enhancement — i18n/theme are cross-cutting but easier to retrofit than to block on

Sources