Skip to content

Repository files navigation

LearnStack

A white-label platform for education businesses that teach live. One binary, one schema, one set of container images — and a language school, a yoga studio and a music school each get their own site, their own content shapes and their own vocabulary.

What differs between them is tenant customization data loaded at provisioning, not compiled code (ADR-0018). There is no Verticals/ folder, and an architecture test fails the build if one appears.

That claim has a stated edge, and it lives in exactly one place: Platform Vision § Genericity boundary. Content shape, presentation and pure rule evaluation are tenant data. Stateful entitlement — credit packs, session quotas — and external capability invocation — running submitted code, scoring speech — are platform features gated by plan. They need a release, not a customization row.


What it does

An education business that teaches live needs more than a course list. It needs a public site it can edit, a catalog that reflects what it actually sells, a way to enrol people and track what they finished, a room to teach in, and a schedule behind it. LearnStack is that whole path, once, for every tenant on it.

flowchart LR
  visitor[Visitor lands on the tenant's site]
  signup[Signs up, takes a placement test]
  enroll[Is enrolled in a course]
  learn[Works through lessons, progress tracked]
  live[Joins a live session in the classroom]

  visitor --> signup --> enroll --> learn --> live
Loading

Each tenant gets three surfaces, served by one deployment:

  • The public site — landing pages, catalog and lesson pages, built from content the tenant authors and rendered from its own branding tokens.
  • Admin Studio — where staff write that content, define their own shapes, manage people and watch what happened.
  • The learner portal — enrolment, progress, assessments, and the in-app live classroom with attendance and optional consent-aware recording.

Four roles, scoped per tenant and per organization: tenant-admin, editor, instructor, learner. A tenant may be one school or a chain of branches — an organization is a sub-unit inside a tenant, and the isolation model treats it as a first-class boundary rather than a filter someone remembers to apply (ADR-0017).

What a tenant authors as data, not as code

This is the part that makes one binary serve unrelated businesses. A tenant declares its own content types, page blocks, lesson item types, level taxonomy, scoring rules, completion rules, custom fields and notification templates — each a JSON Schema or a sandboxed expression, validated before a row is written (ADR-0043).

CEFR levels, a vocabulary card, a placement test that recommends a level, kyu/dan ranks, an asana catalog: all of them are rows. None of them is a branch in any module. When a yoga studio and an English school render different sites from the same deployment, that is the mechanism doing its job — and Phase 10 is the showcase that fills all eight aggregates at once for a single tenant, not the proof that it works.


Quickstart

make install   # one-time per clone: dependencies + git hooks
make dev       # bring the local stack up (Postgres, Keycloak, SeaweedFS, …)
make migrate   # apply the platform + module migration chains
make seed      # write the two demo tenants through the real command path

make help lists every target. make test runs the whole suite — unit, architecture, contract and the Docker-bound integration tests — and it installs what it is about to run.

The seed writes two tenants in unrelated domains, each reachable on its own host:

Tenant Host Shape
demo-english demo-english.learnstack.local Online English school
demo-yoga demo-yoga.learnstack.local Yoga studio

Add both to /etc/hosts pointing at 127.0.0.1. They are not a demo fixture: they are written through ProvisionTenantCommand and the same handlers a request uses, so make seed exercises the production path rather than a second one. From Phase 02d they render side by side in a browser — which is how the genericity claim is tested continuously rather than asserted once at the end.


Where it is today

The platform kernel is finished; the product layers land on top of it. What runs today is multi-tenancy with real isolation, the customization foundation, the audit trail, and the API conventions everything else is built against. What does not run yet is every learner-facing surface — there are no users, no courses and no classroom in this repository right now, and the documentation says so wherever it describes them.

Capability Phase State
Multi-tenancy, organizations, isolation to the database row 02a Running
Tenant customization foundation — content types and level taxonomy 02a Running (2 of the 8 aggregates)
Audit trail, written inside the business transaction 02a Running
API conventions, entitlement socket, foundation ports 02a Running
Two tenants rendering their own sites, side by side 02d Next
Authentication, sessions, events 02b Planned
Users, roles, permissions, admin foundation 03 Planned
Headless CMS, page builder, media library 04 Planned
Course catalog and learning content 05 Planned
Public site renderer and Admin Studio 06 Planned
Enrolment, learner portal, progress 07 Planned
Assessment, notifications, background jobs 08a Planned
Scheduling and booking 08b Planned
In-app live classroom 08c Planned
Billing, integrations, analytics 09 Planned
Production hardening and the demand-gated adapters 11 Planned

The order is dependency-driven, not numeric — the roadmap is authoritative, and it explains why 02d runs before 02b. Every phase document carries the same six sections, and a shipped one carries a delivery record listing what it built and the defects it introduced and caught in its own review rounds.

Three modules hold domain code today — Tenancy, Customization and Audit. The other four module assemblies are scaffolded and empty.


How this codebase defends itself

Most of the interesting engineering here is not a feature. It is the machinery that stops a plausible change from quietly breaking an invariant.

Tenant isolation is defense in depth, from the first migration. Every tenant-owned table carries a tenant column, an EF Core query filter, and a PostgreSQL Row Level Security policy under ENABLE and FORCE — plus an integration test that connects as learnstack_app, the application's own NOBYPASSRLS role, because a test that runs as the table owner passes even when every policy is inert. The canonical policy template is written as SQL in exactly one document, Database Standards; an earlier copy of it lived in four and was wrong in all four.

MUST-class audit commits with the change it describes. Not "the same SaveChanges" — the same transaction, which is what a reader of audit_log actually observes (ADR-0033). An operation the catalogue does not classify is rejected rather than silently unaudited.

The rules the documentation states are the rules that run. The architecture-test catalogue is the canonical list, and it is checked against the suite rather than trusted: an entry that reports a rule Implemented must name a test method that exists, no test may be skipped, and the counts the catalogue publishes about itself are recomputed by a test — because the first version of those counts was wrong in the commit that wrote them.

Every rule ships with a companion that proves it can fail. The lesson the review rounds kept repeating is a rule that cannot tell clean from blind: a guard passes because nothing violates it, and nobody has checked that its mechanism works at all. So a scan is fed a planted violation, and a behavioural claim is mutation-checked — break the production code on purpose, watch the test go red, put it back.


What is in the repository

backend/
  src/            LearnStack.{Api,Domain,Application,Infrastructure*,SharedKernel} + Modules/
  tests/          Unit · Architecture · Contract · Integration (Testcontainers)
  analyzers/      LearnStack.Analyzers — the LS0001 Roslyn rule
frontend/
  apps/web        Next.js 15 App Router — the one application
  packages/       config · ui · sdk (generated from the API's OpenAPI document)
infra/
  compose/        the local dev stack, plus the gated profile and the e2e overlay
  keycloak/ …     per-service configuration
docs/             architecture · decisions · standards · roadmap · modules · glossary
scripts/          seed.sh and the CI helpers

docs/ is not an afterthought here — it is where the decisions live, and the code is expected to agree with it. See CLAUDE.md for the working agreement every contributor (and every agent) follows.


The stack

Layer Choice Notes
Backend .NET 10 · ASP.NET Core · EF Core · MediatR Modular monolith, four cross-module mechanisms and no fifth (ADR-0010)
Database PostgreSQL 18 RLS from day one, four database roles, one canonical policy template (ADR-0003)
Frontend Next.js 15 · TypeScript · React One app with route segments for public, studio and portal (ADR-0009)
Identity Keycloak, self-hosted Two realms: learnstack for tenant users, learnstack-hub for operators
Live classroom LiveKit OSS, self-hosted Cloud available behind the same ILiveClassProvider; a custom SFU is out of scope (ADR-0005)
Storage · Search SeaweedFS · PostgreSQL FTS S3-compatible in production; Meilisearch behind ITenantSearch when scale requires it
Observability OpenTelemetry · Serilog → OTLP Spans enriched centrally; module code never tags a tenant id (ADR-0032)

Vendor adapters are demand-gated. Each has a seam that ships today, an owning phase and a written trigger condition in ADR-0035; a building block missing any of those is not demand-gated, it is missing. Most seams are a port in LearnStack.SharedKernel with a working default — IEventBus / InProcessEventBus for Kafka, ICacheService / InMemoryCacheService for Valkey, ISecretProvider / ConfigurationSecretProvider for Vault, IEntitlementProvider / NullEntitlementProvider for the Hub. Not all of them are: APISIX's seam is the composition root, and audit_log partitioning is schema-internal. Infrastructure Stack Standards § Demand-gated is the table that maps each one, and it is the authority.

One binary, five DeploymentMode values, two of them wired. Development and SaaS run end to end. Dedicated, SelfHostedOnline and SelfHostedAirGapped are prepared seams, not supported deployments, until Phase 11 builds their adapters and suites. The three production categories those values serve are SaaS, Dedicated and Self-Hosted (ADR-0020, 25 — Deployment Models).

SaaS and Dedicated are backed by LearnStack Hub, a separate control plane repository (ADR-0019, GitHub). It is expected at ../LearnStack-Hub so the cross-repository links resolve, and it owns its own roadmap. This repository holds only LearnStack's side of the boundary, in Phase 02c. The contract is governed by two invariants — the Hub stores no tenant content, and every crossing goes through a named adapter (ADR-0034).


Where to start

If you want to… Read
Understand what this is and why Platform VisionMVP Scope
See what is being built next RoadmapPhase 02d
Contribute code CLAUDE.mdEngineering principlesStandards index
Know why something was decided ADR index
Understand the technical shape Technical ArchitectureModule BoundariesCross-Module Contracts
Understand tenancy Tenant IsolationPlatform Tenant + OrganizationTenancy module spec
Understand customization Tenant Customization ModelCustomization module spec
Understand the audit trail Audit SubsystemAudit module specADR-0044
Understand the Hub boundary LearnStack HubDeployment ModelsHybrid License Model
Look up a term Glossary
Every architecture document — 33 of them, grouped

Strategy and shape · 01 Platform Vision · 02 Domain Model · 03 Module Boundaries · 04 Technical Architecture · 05 MVP Scope · 06 Extension Model · 11 Extension Points · 19 MVP Vertical Slice

Platform substrate · 09 Tenant Isolation · 10 Cross-Module Contracts · 15 Events and Outbox · 28 Platform Tenant + Organization · 31 Audit Subsystem · 32 Tenant Customization Model · 33 Cross-Cutting Concerns

Product surfaces · 12 Localization · 13 Identity and Authentication · 14 Frontend Architecture · 16 Media Pipeline · 17 Page Builder · 20 Search · 21 Feature Flags and Entitlements · 23 Data Protection (KVKK / GDPR)

Live classroom · 07 In-App Live Classroom · 08 Cost Model · 18 WebRTC Build vs Adopt

Hub, deployment and edge · 22 Custom Domains · 24 LearnStack Hub · 25 Deployment Models · 26 Hybrid License Model · 27 Custom Domain + TLS · 29 Dapr Integration · 30 API Gateway (APISIX)

Documentation layout

Directory Holds Mutability
docs/architecture/ What we are building, conceptually — 33 numbered documents Editable as the system evolves
docs/decisions/ ADRs: one-time decisions with context and consequences Accepted ADRs change only by dated Amendment or the two bounded corrections in ADR-0041
docs/standards/ The rules every PR is held to, 00–21, each labelled Active or Adopted by what actually enforces it Editable as the team learns; changes cite an ADR
docs/roadmap/ Phases 00–12, with dependency order that filename order does not imply Editable per phase; a shipped phase's delivery record is not rewritten
docs/modules/ Per-module specs, with permission and audit matrices Editable with the module

Conventions

  • English for all documentation (ADR-0007). A tenant's Turkish-facing UI is a separate concern.
  • Mermaid for diagrams, in fenced code blocks, readable as text when unrendered.
  • Conventional Commits, imperative subject, ≤ 72 characters — enforced by the commit-msg hook and re-run in CI, which states no grammar of its own.
  • Single source of truth. The glossary holds terms, ADRs hold decisions, standards hold ongoing rules, the roadmap holds phases. A second copy is a copy that will go stale — the corpus has the scars to prove it.
  • Every pull request is reviewed against Code Review Standards, whose zero-tolerance blocker list is short and non-negotiable.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages