<!-- https://headless-lms.dev/docs/architecture -->

# 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](/docs/adapters), and [plugins](/docs/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](/docs/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.
