Architecture
How everything fits together - easy to extend and modify.
Headless LMS provides the building blocks to compose an LMS from npm packages. The backend is two libraries: @headless-lms/core holds the domain logic, and @headless-lms/server
is the web server plus the wiring that composes core with its adapters.
An installation is a project that composes them with its own config, adapters, and plugins. Everything is swappable, and everything ships with defaults so it works out of the box.
Ports and Adapters
Headless-lms is built on the Ports and Adapters (Hexagonal) architecture. Each domain defines interfaces (Ports) for the features it provides and what infrastructure it needs (e.g. DB methods, file storage, email sending). A domain service implements these 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 nine 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.
| Context | Owns |
|---|---|
| identity | Everyone in the system: the staff User and the learner Student, two separate identity models other contexts reference by id |
| organizations | The tenant root: the organization, its staff memberships and roles, and invitations |
| content | Authored content — courses, modules, activities, drip rules |
| entitlements | Access grants: who can access what, and why |
| progress | Per-student completion and derived progress |
| assets | The org media library, served via presigned URLs |
| integrations | Which third-party integrations an org has connected |
| automations | Trigger/action workflows that react to domain events |
| discussion | Learner comments on activities — their replies, reactions, reports, and moderation state |
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 alongside the contexts, not inside any of them. 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.