2026 · Team · Production
ScholarGrade
AI academic platform for grading, research and writing, integrity and publishing. Rebuilt from a proof of concept into a BFF + gRPC monorepo, now in open beta.
Mission
ScholarGrade serves four personas in one authenticated app: professors grading a class from a single ZIP, students and researchers writing and checking their work, and peer reviewers. Upload a PDF, a Word document or pasted text and get a struck grade and an engraved score in under a minute.
For whom: a Primus Cloud Solutions product with per-seat school plans and individual student and professor tiers. Constraints: the v1 proof of concept was built wide on purpose, as the superset of the idea, in a single Express process on MySQL with about 108 tables and no foreign keys. The brief was to keep the product and rebuild the system so it could be operated, billed and grown.
Manifest
| Layer | Choice | Version | Why |
|---|---|---|---|
| app | React Router 7 + Vite + Tailwind 4 | — | Three apps (client, admin, landing) sharing one token file and one component library. |
| edge | NestJS gateways (REST, Swagger, /api/v1) | — | BFF per audience; they hold Better Auth and never touch the database. |
| services | gRPC central service | — | One database owner; every write path is a typed RPC. |
| data | PostgreSQL 16 + Prisma 7 + Kysely, Redis 7, BullMQ | — | Declarative migrations, typed runtime queries, durable background jobs for the AI tools. |
| money | Stripe (seat-based, one subscription per org pool) | — | Central service owns the key; gateways only verify webhook signatures. |
| infra | Docker Compose, GHCR, SOPS + age per environment, GitHub Actions | — | Tag-driven production deploys with encrypted env files decrypted on the runner. |
Decisions
Strict BFF + gRPC. React Router 7 apps talk REST to NestJS gateways with zero database access; one gRPC central service is the sole owner of Postgres, including the store behind Better Auth.
Prisma for the schema, Kysely at runtime. Migrations stay declarative, queries stay typed and explicit, the same split I use in every product.
Jobs on BullMQ. Every AI text tool runs as a background job with a generic runner, so a slow model never holds a request open.
Plan-gated, multi-provider LLM router. Four providers behind one interface, round-robin across keys, model eligibility resolved from the user’s plan and priced against a monthly quota. Cost control is an architectural concern, not a dashboard.
Seat-based billing on one Stripe subscription. Entitlements resolve as the maximum over a user’s own plan and every organisation seat they hold. A personal Pro owner buying Team seats gets the same subscription repriced, not a second one.
Legacy users imported without passwords. Every v1 account came across with its legacy ids preserved for later child-data remapping, and users re-prove ownership through an emailed claim link behind a recipient allowlist so a misrun can never mail everyone.
A written design contract. “Oxblood & Bone”: zero corner radius, rules and double rules instead of shadows, small caps and drop caps, grades as struck stamps and scores as engraved registers. Tokens live once in the UI library.
Impact
- Commits authored
- 365
- Personas
- 4
- Legacy tables mapped
- ~108
The rebuild is live in open beta on staging, with three frontends, two gateways, a central service and nine shared libraries in one Nx workspace, built and shipped by GitHub Actions into GHCR images and SOPS-encrypted environments. I authored 92% of the commits across the three-month rebuild; the v1 proof of concept, which an AI agent had generated, is claimed here as product definition and system analysis, not as authorship.
The humanizer is a closed-loop optimiser rather than a rewrite prompt: destructure, paraphrase best-of-N, preserve, score against a detector oracle, keep the best, iterate. Its documentation carries an honesty clause, “we never promise 0% AI”, because a platform that grades papers and de-AI-ifies them has to name that tension out loud.
What broke
Frontend Docker builds were being OOM-killed under memory pressure with exit code 137, and the failure was silent: the deploy script piped build output through a tail, which hid the exit code, so every recreate restarted the stale image. Every “my fix did not deploy” in that session was this one bug. The rule that came out of it is blunt and permanent: never truncate build output, and fail the deploy on any non-zero build exit.
Ship log
- First commit of the v2 monorepo
- AI job queue moved onto BullMQ
- Admin command deck: trends, ops health, revenue quality
- Open beta on staging.scholargrade.org