Headless LMS

Architecture

A hexagonal core of bounded contexts, composed with adapters and plugins at build time.

Headless LMS provides the building blocks to compose an LMS from npm packages. The backend is a library — @headless-lms/server — that holds the domain core and web server. An installation is a small project that composes the library with its own config, adapters, and plugins, then runs it. Everything is swappable, and everything ships with a sane default so it works out of the box.

Hexagonal core

The core holds all business logic and depends on nothing outward — no framework, no database, no vendor SDKs. Everything external sits outside the core behind ports:

  • An inbound port is what a context offers: its use cases, like "list courses" or "grant access".
  • An outbound port is what a context needs: capabilities like "store a file" or "send an email".

Adapters implement the outbound ports — Postgres for persistence, Better Auth for identity, S3-compatible storage for files, Resend for email, Hatchet for durable workflows. Because the core only knows the port interfaces, any adapter can be replaced without touching business logic.

The inbound entry point is the HTTP layer. It calls into the core's services and owns no business logic of its own.

Bounded contexts

The core is split into eight self-contained contexts. Each exposes one public surface, and contexts talk to each other only through those surfaces — never by reaching into another context's internals. A use case that spans contexts lives in the context whose responsibility it is; there is no orchestration tier above them.

ContextOwns
identityEveryone in the system: the staff User and the learner Student, two separate identity models other contexts reference by id
organizationsThe tenant root: the organization, its staff memberships and roles, and invitations
contentAuthored content — courses, modules, activities, drip rules
entitlementsAccess grants: who can access what, and why
progressPer-student completion and derived progress
assetsThe org media library, served via presigned URLs
integrationsWhich third-party integrations an org has connected
automationsTrigger/action workflows that react to domain events

Contexts communicate two ways: synchronously, by calling another context's public service directly, and decoupled, through an event bus — domain events fan out to automations and integrations.

Staff and learners

Staff and learners are different populations, modelled separately. Staff hold an org membership carrying a role — owner, admin, or instructor, with instructors scoped to the courses assigned to them. Learners never hold memberships or roles: a learner is a Student, and what they can access is governed entirely by their entitlements.

Reporting

Cross-context reads live in a dedicated reporting layer outside the core. It composes each context's public service into views — the students list, the dashboard overview — and owns no data and no rules. It exists because a domain context depends on nothing outward, whereas reporting must read across many of them.

Plugins and automations

Plugins extend an installation with third-party functionality — notifications, CRM integrations, analytics. A plugin defines actions the automation engine can invoke, so behavior like "send a Slack message when a student is granted access" is a configured automation, not custom code.

Multi-tenancy

The organization is the tenant root: every org-scoped record belongs to exactly one organization. Users are global — the link between a user and an org is their membership — while a student belongs to exactly one org. Authentication is a single global account; the boundary a request comes through determines which organization it operates in.

Better Auth is the system of record for accounts, organizations, and memberships. The core holds a read-only mirror and reacts to changes; every member write goes through Better Auth. Swapping it for another provider is a data migration, not an adapter swap.

API surface

The HTTP API is schema-first. Request and response shapes come from a shared Zod contract, which every route validates against — and the same contract produces the OpenAPI spec, from which the typed SDK is generated. The API, docs, and client can't drift apart. The admin dashboard and student portal are SDK consumers like any other frontend. An MCP endpoint exposes the platform to AI agents, authenticated via OAuth.

Dependency direction

Dependencies point inward, always:

  • Entry points (HTTP, CLI) → application wiring → core
  • Adapters → core, via ports
  • The core points nowhere outward

The application layer builds the object graph — instantiating adapters and wiring each context's service — and starts nothing; each entry point starts its own process.

On this page