Skip to content
Last updated Give Feedback

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

┌──────────────────────────────────────────────────────────────────────────────┐
│ 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 Stripe invoice.paid, runs key rotation (kid), enforces revocation. Talks to apps/api only via the heartbeat endpoint that every customer install hits weekly. Not co-located with customer tenants.
  • packages/verdaccio-auth-license-jwt — Verdaccio plugin that gates npm install of @ecommus-plugin/* and @ecommus/theme-* against the customer license JWT. Powers npm.ecommus.cloud.
LayerCan import fromCannot import from
apps/adminpackages/*apps/api, apps/storefront-astro, apps/super-admin
apps/super-adminpackages/*apps/api, apps/storefront-astro, apps/admin
apps/storefront-astropackages/*, themes/*apps/api, apps/admin, apps/super-admin
apps/apipackages/*, plugins/*, themes/*apps/admin, apps/super-admin, apps/storefront-astro
apps/license-serverpackages/core (license types), packages/db (its own tables)apps/api, any UI app
packages/coreNode.js built-ins onlyOther packages
packages/dbpackages/coreapps/*
packages/plugin-sdkpackages/coreapps/*, packages/db
packages/theme-sdkpackages/coreapps/*, packages/db
plugins/*packages/plugin-sdk, packages/coreapps/*, packages/db (use ctx.db.exec())
themes/*packages/theme-sdkapps/*, packages/db

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 page

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

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 overrides

The 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).

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; resolveTenant reads the only row and caches it.
  • subdomain (SaaS) — extracts the tenant slug from req.host (e.g. acme.mystore.cloudacme).
  • header (B2B integrations) — reads X-Ecommus-Tenant (admin token required).

See Multi-tenancy for the isolation model + cross-tenant audit (CLAUDE.md §4).

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/slots

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

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

FilePurpose
apps/api/src/bootstrap.tsDB init + plugin loading + plugin migrations
apps/api/src/server.tsFastify server factory + Helmet/CORS/rate-limit + OpenAPI dump
apps/api/src/middlewares/auth.tsrequireAuth, resolveTenant, require{Feature,LicensedTheme,LicensedPlugin,NichePackage}
apps/api/src/plugins/integrate.tsConnects 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.tsStandalone license-issuing Fastify service
packages/core/src/license.tsLicense JWT type, verify, computeGraceState
packages/core/src/events.tsEventBus + all domain event types
packages/plugin-sdk/src/loader.tsPlugin manifest validation, topo sort, init
packages/plugin-sdk/src/registry.tsPlugin context factory + registration storage
packages/theme-sdk/src/manifest.tsTheme manifest schema, slot definitions
packages/db/src/schema/All Drizzle table definitions (one file per domain)
packages/db/src/migrate.tsCore migrations + runPluginMigrations()
infra/Caddyfile.templateCustomer-facing reverse proxy template