Architecture
Modulix is a modular, multi-tenant ERP/CRM platform written in TypeScript end to end. A deliberately small core handles identity, tenancy, authorization, settings, navigation and module management; every business capability ships as a module that can be installed, enabled per organization, upgraded and removed without touching the core.
This page explains how the pieces fit together. It is the background for Building modules, the Plugin and extension API, the REST API and Deploying Modulix.
:::note Terminology In the code, an organization is a tenant. The two words mean the same thing on this page. :::
Technology
| Layer | Technology |
|---|---|
| Web | React 19, TanStack Start / Router / Query, shadcn/ui, Tailwind CSS v4 |
| API | NestJS 11 on Fastify, zod (nestjs-zod), OpenAPI 3 at /api/docs |
| Data | Drizzle ORM, PostgreSQL or MySQL from one schema definition |
| Repository | pnpm workspaces, Turborepo, Biome, Vitest |
| Operations | Docker (multi-target image), Docker Compose, Helm chart |
Principles
- Small core, everything else is a module. The core owns identity, tenancy, authorization, settings, navigation and module lifecycle. Business features never live in
apps/*. - The core uses its own extension points. The core is itself described by a system manifest (
apps/api/src/core-manifest.ts), so its permissions, menu and settings go through the same channels as any module. - API-first. The web app is just one client of the versioned REST API (
/api/v1, OpenAPI at/api/docs). Integrations use API keys or OAuth clients. - Runtime enablement, build-time installation. Adding code to a deployment means adding a package and rebuilding the image. Installing, enabling, disabling and upgrading happen at runtime, with no restart.
- Portable persistence. One schema definition targets PostgreSQL and MySQL.
Repository layout
apps/api NestJS host: boots the kernel, mounts the core and every discovered module
apps/web TanStack Start host: app shell, sign-in, administration; mounts module routes
packages/sdk isomorphic module contract: defineModule, hooks, permissions, menu, dependency graph
packages/sdk-server NestJS helpers for module authors (decorators, tokens, tenant repositories, record toolkit)
packages/sdk-web React helpers for module authors (Slot, usePlatform, useCan, useSettings, API client)
packages/kernel module discovery, registry and lifecycle (install / enable / upgrade / uninstall)
packages/db portable Drizzle schema DSL, drivers, tenant scoping, migrator, core schema
packages/ui design system (shadcn/ui components and theme tokens)
packages/cli `modulix generate` (web glue) and `modulix module:new` (scaffolder)
modules/* first-party modules (notes, contacts, sales, accounting, inventory, ...)
plugins/ drop-in third-party packages (same format as modules)
deploy/ Dockerfile, Compose files, Helm chart
Packages and dependencies
┌──────────── @modulix/sdk (isomorphic: manifest, hooks, permissions, menu, graph)
│
@modulix/db ─────┤ portable tables, drivers, tenant scoping, migrator, core schema
│
@modulix/kernel ─┘ discovery · registry · lifecycle (framework-agnostic)
│
@modulix/sdk-server Nest decorators, injection tokens, repositories ──► module server code
@modulix/sdk-web Slot, usePlatform, useCan, useSettings, API client ──► module web code
@modulix/ui shadcn/ui design system ──► module web code
│
apps/api (NestJS host) apps/web (TanStack Start host)
Modules depend only on the SDK packages, @modulix/db and @modulix/ui, never on apps/*.
Module discovery
A module is any package whose package.json has a modulix field:
"modulix": {
"manifest": "./dist/manifest.js",
"server": "./dist/server/index.js",
"web": "./src/web/index.tsx",
"routes": "./src/web/routes",
"migrations": "./migrations"
}
API. At boot, the kernel scans the dependencies of apps/api and the directories listed in MODULIX_MODULE_DIRS (when that variable is empty and the API runs from the repository, the repository's plugins/ directory). For each module it:
- validates the manifest with zod and checks that the manifest version matches
package.json; - refuses ids reserved by the core (
auth,users,settings,modules, ...); - resolves the dependency graph with semver checks against
requires.coreandrequires.modules. Cycles and missing dependencies stop the boot with a clear error.
Web. modulix generate runs before vite dev and vite build and scans the dependencies of apps/web. It writes three files under apps/web/src/generated/:
| File | Contents |
|---|---|
modules.ts | Static imports of every module's web entry |
routes.ts | A TanStack virtual route config that mounts each module's routes directory at /m/<id> inside the signed-in layout (and publicRoutes at /p/<id>) |
sources.css | Tailwind @source entries for module sources |
Module routes stay fully type-safe and code-split.
Lifecycle
Installation is deployment-wide; enablement is per organization.
| Operation | Scope | Effect |
|---|---|---|
| install | deployment | Installs missing dependencies first, runs the module's migrations, writes a core_system_modules row, calls onInstall, emits module.installed |
| upgrade | deployment | Runs new migrations, calls onUpgrade(from, to), bumps the recorded version, emits module.upgraded. The migrate command does this for every installed module |
| uninstall | deployment | Refused while installed dependents exist. Calls onUninstall, removes organization enablement and the module's role grants. Tables are kept unless the module drops them |
| enable | organization | Enables dependencies first, upserts core_tenant_modules, applies defaultGrants to the organization's roles, calls onEnable, emits module.enabled |
| disable | organization | Refused while enabled dependents exist. Calls onDisable, emits module.disabled |
Modules with autoEnable: true (system modules) are installed on migrate and enabled for every organization.
Platform administrators (super admins) install, upgrade and uninstall modules in Administration → Modules or with the API CLI. Organization administrators enable and disable them for their own organization.
Why enabling a module needs no restart
- Every discovered module's Nest module is mounted at boot under
/api/v1/<moduleId>, and each of its controllers is automatically tagged with its module id. AccessGuardreturns 404 when the module is not enabled for the caller's organization.- On the web, the signed-in layout returns 404 for
/m/<id>/*when the module is disabled, and menus and slots only include enabled modules. - Enablement is cached per organization for 5 seconds and invalidated on change.
Request pipeline (API)
- Fastify plugins: helmet, cookies, rate limiting (global, and stricter on sign-in, sign-up and token endpoints), CORS.
- AccessGuard, a single global guard that runs in a fixed order:
- Authenticate: a
BearerJWT (HS256, short-lived), an OAuth client token, or an API key (X-Api-KeyorBearer mx_…), unless the route is@Public(). - Resolve the tenant, in this order: the
X-Tenantheader (id or slug), the token'stidclaim, then the user's only membership if they have exactly one. Membership is required, except for platform super admins. API keys and OAuth clients are bound to one tenant. - Module scope: 404 if the controller's module is disabled for the tenant.
- Permissions:
@RequirePermission(...), with wildcard support (*,notes.*).
- Authenticate: a
- ContextInterceptor runs the handler inside an
AsyncLocalStorageRequestContext (tenant id, user id, permissions, actor, IP address, user agent). Repositories, settings and audit read it implicitly. - Validation: a zod validation pipe. Errors are rendered as RFC 7807
application/problem+jsonbyProblemFilter, and every refused request (403) is written to the audit log asaccess.denied.
Multi-tenancy
- Model: a shared schema; every tenant-owned table has a
tenant_idcolumn. - Enforcement:
TenantRepositoryandtenantScope()from@modulix/dbaddtenant_id = <current tenant>to every read, update and delete, and inject it on insert. Calling them outside a tenant context throws. The isolation tests run on both dialects. - Transactions:
inTransaction(db, fn)opens an ambient transaction that repositories join automatically throughcurrentDb(). - Users are global. Memberships link users to tenants, and roles are per tenant. One account can belong to several organizations.
Authentication and authorization
Passwords are hashed with argon2id. Login timing is constant for unknown emails.
Access token: a JWT, 15 minutes by default (ACCESS_TOKEN_TTL), held in memory by the web app.
Refresh token:
- A random opaque token, stored hashed in the database.
- Rotated on every use. Reusing a rotated token revokes the whole token family.
- The web app keeps it in an
httpOnly,SameSite=Laxcookie scoped to/api/v1/auth. The web server proxies/api/*to the API, so the cookie stays first-party.
API keys: mx_<prefix>_<secret>, stored as SHA-256 and bound to one tenant. Their scopes are permission keys that cannot exceed the creator's own permissions. Keys can expire and be revoked.
OAuth clients: for server-to-server access with the client credentials grant. A client exchanges its id and secret for a one-hour bearer token limited to its scopes; revoking the client stops its tokens at once.
RBAC:
- Modules declare permissions. Roles store permission strings.
- Each tenant gets the system roles Owner (
*), Administrator, Member and Read-only. Modules extend the defaults throughdefaultGrants; the Read-only role gets every*.readpermission unless a module says otherwise. - Permissions a module declares
scopedcan be granted for own, team or all records (stored askey:ownandkey:team). - The same check (
hasPermissionfrom@modulix/sdk) runs in the API guard and in the web app (useCan,<Can>). - Super admins (a platform-level flag) can install, upgrade and uninstall modules and create tenants.
File storage
The core STORAGE service keeps binary objects for modules (documents, attachments, receipts, avatars): local disk (STORAGE_DRIVER=local, STORAGE_DIR) or an S3-compatible bucket (STORAGE_DRIVER=s3, S3_*). API replicas must share it, so multi-replica deployments (Helm) use S3.
Uploads are streamed: application/octet-stream bodies are passed to handlers as raw streams, and the web proxy streams request and response bodies.
Stored credentials
Modules that keep credentials (an organization's email provider API key, a webhook signing secret) encrypt them with the core SECRETS service: AES-256-GCM with SECRETS_KEY (32 bytes). Without it, the key is derived from JWT_SECRET, so rotating JWT_SECRET would make stored credentials unreadable. Set SECRETS_KEY in production. Decrypted values never leave the server.
Settings
Each module declares zod schemas for organization (tenant) and user settings, and every field must have a default.
- Resolution: the module's schema defaults, then platform values (set by platform administrators for every organization), then the organization's values (or the person's values for user settings). Each level overrides the previous one.
- Writes are validated with a strict partial schema (422 otherwise).
- Reads validate per field, so a stale stored value never breaks a module.
GET /api/v1/settings/schemasreturns JSON Schemas. The web app renders settings forms from them automatically, so modules need no settings UI code.
Database portability
defineTable(name, (c) => ({…})) uses a portable column factory (c.id(), c.tenantId(), c.varchar(), c.decimal(), c.json(), c.timestamps(), c.index(), ...). At runtime it produces the native pg-core or mysql-core table, depending on DB_DIALECT, which is inferred from DATABASE_URL.
- Types: tables and the
Databasehandle are typed as the PostgreSQL flavour for precise inference. The query-builder subset used by the platform is identical on MySQL. - Ids are UUIDv7 generated by the application, so
RETURNINGis never needed. - Conflicts: use the
upsertandinsertIgnorehelpers instead of dialect-specific conflict clauses. - Migrations:
- drizzle-kit generates them once per dialect into
migrations/pgandmigrations/mysql. - The kernel's migrator applies them per module, in dependency order, and records them in
core_module_migrations. - It holds a database lock (
pg_advisory_xact_lockorGET_LOCK), so concurrent migrate jobs and replicas are safe.
- drizzle-kit generates them once per dialect into
Web shell
The web app is TanStack Start with server-side rendering for the document shell. The signed-in layout is client-rendered (ssr: false), because the session lives in memory and is restored from the refresh cookie.
GET /api/v1/me/contextprovides the user, their organizations, the current organization, enabled modules, effective permissions and the permission-filtered menu. The menu passes through themenu.buildfilter hook first.PlatformProviderexposes that context and the API client to the core and to modules.- The shell includes a sidebar built from the menu, an organization switcher, a ⌘K command palette (menu items plus module commands), light, dark and system themes, and slots (
dashboard.widgets,topbar.actions,sidebar.footer,profile.sections, ...). - Public pages (for example public forms) are mounted under
/p/<module>/…in a bare layout without sign-in.
Languages and formats
The interface is available in English, French, German and Spanish, with typed per-module message catalogs (defineMessages in @modulix/sdk, shared by the web app and the API). Each person's language and time zone come from their profile, then the organization's settings, then their browser. The organization also sets the date format and its default currency. Money is formatted per currency, with no exchange rates.
API error messages and audit entries stay in English. Right-to-left languages are not supported yet.
Background work
Some modules run periodic workers inside every API process: webhook deliveries, import and export jobs, workflow runs (Automation), notification delivery, task reminders, overdue invoice checks and clean-up jobs. Queued work (webhook deliveries, import and export jobs, workflow runs, notifications) is claimed in the database before it runs, so several replicas can work side by side. Most workers can be switched off per process with an environment variable (for example AUTOMATION_RUNNER=off, WEBHOOKS_WORKER=off, NOTIFICATIONS_WORKER=off), which lets you move them to a dedicated process later. See Deploying Modulix.
Deployment model
- Image: one multi-target Dockerfile.
apiis apnpm deploy-ed production tree.webis the self-contained Nitro.output.
- Migrations: run as a separate one-shot
migrateorseedprocess (a Compose service, or a Helm pre-install / pre-upgrade Job).AUTO_MIGRATE=trueis available for single-node setups. - Statelessness: the API and web servers are stateless and scale horizontally.
- Per-instance caches have short TTLs (5 seconds for enablement and permissions), so state converges across replicas.
- Health endpoints:
/health/liveand/health/ready.
Known limits
- Hooks run in-process and synchronously with the request. An outbox and queue (BullMQ/Redis) are planned behind the same
HookBusAPI. - Workflow runs (Automation module) are persisted and executed by a polling runner in each API process, claimed with a token and a lock. A dedicated worker process can be introduced by turning the runner off (
AUTOMATION_RUNNER=off) on API replicas. - There is no PostgreSQL row-level security yet; isolation is enforced in the repository layer. RLS policies can be added per table as defence in depth.
- Public pages (
/p/<module>/…, for example public forms) are protected by per-IP rate limits, signed sessions and a trap field, not by a CAPTCHA. - Custom field values are stored as one row per field and record (JSON value). Record types whose owner calls
entityExtensions(contacts, companies, users, and record types built with the record toolkit) include them in responses ascustomFieldsand acceptcf.<key>filters. For other record types, read them from/api/v1/custom-fields/values/…. - Reserved for later: OIDC/SSO as a plugin, and a per-organization module marketplace.