Binary Semaphore

Ascent

A cohort-based EdTech platform built as a microservices system, with the seat and event problems solved properly rather than papered over.

View on GitHub
NestJSTypeScriptPostgreSQLKafkaMicroservices

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

github.com/BiSemaphore/ascent