Architecture Overview
Architecture overview
Section titled “Architecture overview”ecommus is a monorepo with strict layer separation. Each layer has a single responsibility and can only import from layers below it. The same code ships in every customer copy — a license JWT decides what’s enabled, a niche package decides which themes/plugins install (see License).
Layer diagram
Section titled “Layer diagram”┌──────────────────────────────────────────────────────────────────────────────┐│ apps/admin apps/super-admin apps/storefront-astro ││ (Next.js 15, :3001) (Next.js 15, :3002) (Astro 5, :3000) ││ Tenant operator UI Platform / tenant Public storefront ││ catalog, orders, cust. provisioning, license, SSR per tenant + ││ niche-package activation white-label, marketplace cached fragments │└──────────────┬───────────────────┬─────────────────────────┬─────────────────┘ │ HTTP │ HTTP │ HTTP┌──────────────▼───────────────────▼─────────────────────────▼─────────────────┐│ apps/api (Fastify 5, :4000) ││ Helmet + CORS + rate-limit + Zod input validation on every route ││ preHandlers: requireAuth · resolveTenant · requireTenant · requireFeature ││ requireLicensedTheme · requireLicensedPlugin · requireNichePackage ││ /api/admin/* · /api/storefront/* · /api/super-admin/* · /webhooks/* ││ Plugin routes auto-registered into the right scope by integratePlugins() │└─────┬───────────────────────────────────┬─────────────────────────────┬──────┘ │ │ │┌─────▼─────────────┐ ┌──────────────────▼─────────────┐ ┌────────────▼──────────┐│ packages/core │ │ packages/db │ │ packages/plugin-sdk ││ Business logic │ │ Drizzle schema + migrations │ │ Plugin context + ││ pricing, tax, │ │ pglite (dev) / Postgres 16 │ │ loader contract ││ promotions, │ │ + pgvector (search) │ │ Hook/filter/route ││ events, license, │ │ + license-server tables │ │ registration API ││ validation │ │ │ │ │└─────────┬─────────┘ └──────────────┬─────────────────┘ └────────┬──────────────┘ │ │ │ │ ┌──────────────▼─────────────┐ │ │ │ packages/theme-sdk │ │ │ │ Theme manifest + slot map │ │ │ │ contract │ │ │ └──────────────┬─────────────┘ │ │ │ │┌─────────▼─────────────┬─────────────▼──────────┬───────────────────▼──────────┐│ themes/ (auto) │ plugins/ (auto) │ packages/client (SDK) ││ theme-fashion, │ niche-booking, │ Typed REST client used by ││ theme-services, │ niche-real-estate, │ admin, super-admin, server- ││ theme-realestate, │ niche-hotel, │ side renderers, third-party ││ theme-travel │ niche-ecommerce, │ integrations. ││ │ efactura-ro, │ ││ │ payment-{stripe, │ ││ │ netopia, euplatesc}, │ ││ │ marketplace-emag, ... │ │└───────────────────────┴────────────────────────┴──────────────────────────────┘Out of band:
apps/license-server— separate Fastify service that signs the customer JWT (Ed25519), receives Stripeinvoice.paid, runs key rotation (kid), enforces revocation. Talks toapps/apionly via the heartbeat endpoint that every customer install hits weekly. Not co-located with customer tenants.packages/verdaccio-auth-license-jwt— Verdaccio plugin that gatesnpm installof@ecommus-plugin/*and@ecommus/theme-*against the customer license JWT. Powersnpm.ecommus.cloud.
Strict import rules
Section titled “Strict import rules”| Layer | Can import from | Cannot import from |
|---|---|---|
apps/admin | packages/* | apps/api, apps/storefront-astro, apps/super-admin |
apps/super-admin | packages/* | apps/api, apps/storefront-astro, apps/admin |
apps/storefront-astro | packages/*, themes/* | apps/api, apps/admin, apps/super-admin |
apps/api | packages/*, plugins/*, themes/* | apps/admin, apps/super-admin, apps/storefront-astro |
apps/license-server | packages/core (license types), packages/db (its own tables) | apps/api, any UI app |
packages/core | Node.js built-ins only | Other packages |
packages/db | packages/core | apps/* |
packages/plugin-sdk | packages/core | apps/*, packages/db |
packages/theme-sdk | packages/core | apps/*, packages/db |
plugins/* | packages/plugin-sdk, packages/core | apps/*, packages/db (use ctx.db.exec()) |
themes/* | packages/theme-sdk | apps/*, packages/db |
Request flow — admin write
Section titled “Request flow — admin write”A representative path: POST /admin/products/:id (edit a product) from the tenant admin UI.
Browser → POST /admin/products/abc123 (Next.js admin server action) → @ecommus/client .admin.products.update(id, body, { csrfToken }) → fetch('https://api.mystore.ro/api/admin/products/abc123', { headers: { Authorization: 'Bearer <access-jwt>', 'X-CSRF-Token': '...' }, body: JSON.stringify(body) }) → Fastify route handler preHandler chain: 1. requireAuth — verifies JWT, sets req.user 2. resolveTenant — sets req.tenantId from host / session / single-tenant fallback 3. requireTenant — refuses if no tenant resolved 4. requireLicensedTheme("theme-fashion") — only on theme-bound routes 5. validateInput(zodSchema) handler: - load product WHERE id=$1 AND tenantId=$2 (always tenant-scoped) - mutate via Drizzle - emit ProductUpdated event on the EventBus - return updated product → Response (JSON) → Next.js revalidates the cache + re-renders the pageThe plugin system can add preHandler steps too. For example, the audit-log plugin attaches a post-mutation hook that writes every successful admin write to the audit table — without touching apps/api source.
Request flow — storefront read
Section titled “Request flow — storefront read”GET /produs/<slug>:
Browser → CDN (cached) → apps/storefront-astro (SSR) → fetch('https://api.mystore.ro/api/storefront/products/<slug>') → resolveTenant (host-based) → public route, no auth → Drizzle SELECT (tenantId-scoped) → render Astro page with theme overridesThe storefront is the only app that can render different markup per theme — it loads the active theme’s slot definitions from packages/theme-sdk and composes the page from registered slot fillers. Plugins can register slot fillers too (e.g. niche-booking adds a date-picker slot to theme-services).
Multi-tenant architecture
Section titled “Multi-tenant architecture”Every DB query in apps/api is filtered by tenantId. The middleware that sets req.tenantId is resolveTenant. There are three modes:
single(dev default) — one tenant in the DB;resolveTenantreads the only row and caches it.subdomain(SaaS) — extracts the tenant slug fromreq.host(e.g.acme.mystore.cloud→acme).header(B2B integrations) — readsX-Ecommus-Tenant(admin token required).
See Multi-tenancy for the isolation model + cross-tenant audit (CLAUDE.md §4).
Plugin lifecycle
Section titled “Plugin lifecycle”bootstrap(app) → loadPlugins(pluginsDir) readManifest() for each plugin/ validateManifest() against the @ecommus/plugin-sdk schema topologically sort by dependency order import(entrypoint) and call init(PluginContext) ctx.registerHook(eventName, handler) ctx.registerFilter(filterName, handler) ctx.registerRoute({ method, path, schema, handler, scope }) ctx.registerAdminSlot({ slot, component, condition }) ctx.registerMigration({ name, sql }) // applied at boot ctx.registerNichePreset({ niche, theme, plugins }) → runPluginMigrations(_db, registry.migrations) writes a row to _ecommus_plugin_migrations after each apply (idempotent) → integratePlugins(app) attach hook handlers to the EventBus register Fastify routes in the right scope (admin / storefront / super-admin) hand admin-slot manifests to apps/admin via /api/admin/slotsA plugin failing to init() does not take the API down — it’s logged and skipped. The framework boot is fault-tolerant per CLAUDE.md §0.4.
Theme lifecycle
Section titled “Theme lifecycle”loadThemes(themesDir) → readManifest() for each theme/ → validateManifest() against the @ecommus/theme-sdk schema → register slot map (which slots the theme exposes, default fillers)storefront-astro at request-time: → resolve active theme for tenant (DB) → check requireLicensedTheme(themeId) against the customer license JWT → render layout from theme manifest → fill slots: theme defaults first, plugin slot fillers overlaid by priorityDistribution + license cycle
Section titled “Distribution + license cycle”Customer install boot → load .env including ECOMMUS_LICENSE → verifyLicenseJwt(env.ECOMMUS_LICENSE, publicKeys[kid]) → on success, populate req.license on every request → daily heartbeat to license.ecommus.cloud (or self-hosted server) payload: { install_id, kid, last_seen, plugin_versions, kpis } response: { ok, revoked: boolean, new_jwt: optional } → if revoked: log + transition to grace state "locked" → if new_jwt: hot-swap the in-memory token (no restart needed)The grace algorithm — ok/warning/reduced/locked — is computeGraceState(lastHeartbeat) in packages/core/src/license.ts. See License.
Key files
Section titled “Key files”| File | Purpose |
|---|---|
apps/api/src/bootstrap.ts | DB init + plugin loading + plugin migrations |
apps/api/src/server.ts | Fastify server factory + Helmet/CORS/rate-limit + OpenAPI dump |
apps/api/src/middlewares/auth.ts | requireAuth, resolveTenant, require{Feature,LicensedTheme,LicensedPlugin,NichePackage} |
apps/api/src/plugins/integrate.ts | Connects plugin registry to live Fastify app, scope-aware |
apps/super-admin/src/app/(authenticated)/... | Tenant provisioning, license panel, marketplace publisher |
apps/license-server/src/server.ts | Standalone license-issuing Fastify service |
packages/core/src/license.ts | License JWT type, verify, computeGraceState |
packages/core/src/events.ts | EventBus + all domain event types |
packages/plugin-sdk/src/loader.ts | Plugin manifest validation, topo sort, init |
packages/plugin-sdk/src/registry.ts | Plugin context factory + registration storage |
packages/theme-sdk/src/manifest.ts | Theme manifest schema, slot definitions |
packages/db/src/schema/ | All Drizzle table definitions (one file per domain) |
packages/db/src/migrate.ts | Core migrations + runPluginMigrations() |
infra/Caddyfile.template | Customer-facing reverse proxy template |
See also
Section titled “See also”- Multi-tenancy — tenant isolation, scopes, audit
- License — JWT shape + tier matrix + grace
- Plugin Overview — author guide, hooks/filters, registration
- REST API Overview — endpoint scopes, auth, rate limits
- ADR-015 — niche package distribution model