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>
16 KiB
Architecture Patterns
Domain: Modular portal platform with marketplace, multi-tenancy, configurable dashboard, and desktop wrapper Researched: 2026-06-18
Recommended Architecture
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
modulestable - Module code lives in
src/modules/<module-name>/with a standard interface - Activation is per-tenant:
module_activationstable 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
- Multi-Tenant Databases with Postgres Row-Level Security
- AWS: Multi-tenant data isolation with PostgreSQL Row Level Security
- Approaches to implementing multi-tenancy in SaaS applications - Red Hat
- Node.js Plugin Architecture: Build Your Own Plugin System
- How to Build Plugin Architecture in Node.js
- Building Customizable Dashboard Widgets Using React Grid Layout
- react-grid-layout - GitHub
- Micro-Frontend Architecture with Module Federation
- Tauri vs Electron: The Complete Developer's Guide (2026)
- Tauri in 2026: Build Cross-Platform Desktop Apps
- Using a Reverse Proxy to Expose Multiple Microservices Through a Single Port in Docker Compose
- Developing a Multi-Tenant SaaS Application: The 2026 Architecture Guide