Ascent
A cohort-based EdTech platform built as a microservices system, with the seat and event problems solved properly rather than papered over.
Five services. One database each. No shared tables.
Two people, one last seat. Only one gets in.
Overview
Ascent is a cohort-based learning platform in the shape of Scaler or a bootcamp: instructors author programs, courses, modules, and lessons; admins open a scheduled cohort with a fixed number of seats; learners buy a seat and move through the content as a group. Content authoring is the CMS side, delivery is the learner side, one product with two audiences.
It is built as a microservices system on purpose. The interesting part of a platform like this is not the CRUD, it is what happens when a hundred people hit a five-seat cohort at the same time, or when a payment succeeds but the service that grants the seat is down. Those are the problems the architecture is arranged around.
The build is phased and each phase ships as a tagged release: auth and content behind a gateway, then cohorts and concurrency-safe enrollment, then the Kafka event backbone and a projection service, then payments. The roadmap, architecture decisions, and per-service data model are written down in the repo rather than living in someone's head.
At a glance
- Type
- Cohort-based LMS, microservices
- Services
- auth · content · cohort · payment · progress
- Data
- PostgreSQL (one per service) · MongoDB · Redis
- Async
- Kafka (KRaft), transactional outbox
- Gateway
- Nginx (routing, rate limiting)
- Frontend
- Angular (standalone, signals)
- Payments
- Stripe Checkout + webhooks
- Local run
- Docker Compose, one command
- Status
- v0.3.0 shipped, payments in progress
How it works
Everything enters through one door
An Nginx gateway is the single entry point on port 8080, routing /api/* to the service that owns it and doing per-IP rate limiting at the edge, so no service has to reimplement it.
Each service owns its data
Auth, content, cohort, payment, and progress each have their own Postgres database with Drizzle migrations. Services share code through workspace libraries, never through tables, so one service cannot quietly depend on another's schema.
A seat is claimed atomically
Enrollment runs an atomic conditional update (WHERE seats_taken < seat_limit) inside a transaction, backed by a unique constraint. The database decides the winner, so two concurrent requests for the last seat cannot both succeed.
State changes become events
The event is written to an outbox table in the same transaction as the state change. A relay polls it with FOR UPDATE SKIP LOCKED, publishes to Kafka, and stamps it published, so there is no dual write and nothing is lost when the broker is down.
Consumers project, idempotently
The progress service holds no source of truth. It consumes learner.enrolled and lesson.completed and rebuilds per-learner state from them, deduplicating on a processed_events table so at-least-once redelivery never double counts.
Capabilities
01Concurrency-safe enrollment, load-tested
Reading the seat count and then writing it back is the classic lost-update bug, and it only shows up under real traffic. Ascent claims the seat in one conditional statement instead. Load-tested at 20 concurrent enrollments against a 5-seat cohort: exactly 5 learners get in, every run, with no overselling.
02Transactional outbox instead of dual writes
Writing to the database and then publishing to Kafka is two writes that can disagree: the row commits, the publish fails, and the rest of the system never hears about it. The outbox makes the event part of the same transaction, and a separate relay does the publishing, which turns an impossible guarantee into an ordinary one.
03Payments decoupled from seat allocation
Stripe Checkout takes the money and a signature-verified webhook writes payment.completed to the payment service's own outbox. Cohort consumes that event and enrolls the buyer through the same concurrency-safe path. Paid cohorts reject direct enrollment; free ones still enroll straight through.
04One service per bounded context
Auth, content, cohort, payment, and progress are split by what they own, not by layer. Each is a NestJS service with validated env config that fails fast, a health check that pings its datastores, graceful shutdown, Swagger docs, and RBAC from a shared @ascent/auth library.
05The right store for each job
Postgres holds the transactional core, one database per service. MongoDB takes the append-heavy activity and audit logs, kept off the relational path. Event contracts live in a shared @ascent/contracts package so producers and consumers cannot drift.
06A frontend with real layering
The Angular app follows component to facade to repository to HttpClient, feature-first with lazy routes, auth and error interceptors, OnPush change detection, and a role directive for permission gating. A public catalog lets anonymous visitors browse open cohorts over safe projections that never expose internal fields.
Want the code?
Ascent is open source and built in public.