[ case study ]

AI-Native Learning Platform & The Over-Engineering Trap

I built a technical monster: a modular monolith, a polymorphic notes engine and a custom AI orchestration layer that looked like a post-seed SaaS. This is the story of an ambitious project that aimed to reinvent learning, and instead became my most important lesson in building an MVP.

[ archived ]

Full-Stack Developer & Product Owner

Stack

Main workspace: study group, modules (Notes, Quizzes, Flashcards) and math block editor.

Main workspace: study group, modules (Notes, Quizzes, Flashcards) and math block editor.

Goal

Eliminate friction in learning. Move from a static "notes warehouse" to an environment where AI natively turns knowledge into interactive quizzes, flashcards and sessions with a virtual tutor.

Scroll to read

01 - Introduction

GroupNote - AI-native collaborative learning

Most case studies you read are success stories. This one is not.

Groupnote is a learning platform meant to pair collaborative note-taking with native AI support. Technically, it is the most ambitious system I have designed: a notes engine built on polymorphic jsonb, real-time sync that often bypassed the backend, and custom LLM orchestration (Gemini) in a Python microservice talking to the .NET monolith, with a React frontend.

It looked like a post-seed SaaS: shared editors, an AI tutor, quizzes, flashcards, and a full limits and subscription system.

The problem? I never shipped it.

This case study is about pairing modern architecture with the classic "Founder Trap" - severe scope creep and building a large system before validating a small MVP. It is the project that taught me to think like a product engineer, not only like a programmer.

Status: Archived (never published publicly).

Role: Full-Stack Developer & Product Owner

Goal: Build an AI-native learning environment where AI turns notes into quizzes, flashcards, and interactive tutor sessions by design.

Product shell: course context, primary tabs (Dashboard, Notes, Quizzes, Flashcards, Open Questions), and entry to Lernie study mode.
Notes workspace: course header, invite flow, block editor with import and AI assistant, and block-type menu (Text, headings, Code, Math) - the polymorphic surface backed by jsonb.

Tech stack

Backend & architecture (modular monolith)

  • C# / .NET 9 (Minimal APIs, CQRS with MediatR, FluentValidation)

  • PostgreSQL (heavy use of jsonb for the block editor document model)

  • Redis (caching)

Frontend & UX

  • React 18 & Vite 5 (TypeScript)

  • TanStack Query (data fetching & state management)

  • Radix UI & Tailwind CSS (design system)

  • Framer Motion (micro-interactions)

Real-time & AI layer

  • Supabase (Auth & Realtime postgres_changes for live note sync)

  • Python microservice (Gemini API integration, isolating AI orchestration from .NET domain logic)

  • Docker & Aspire AppHost (local orchestration of the full stack)

02 - Problem & Idea

The Idea: From a knowledge warehouse to an active learning environment

Most mainstream note-taking tools (Notion, Microsoft OneNote, Evernote) are optimized for one primary job: knowledge storage. They behave like infinite digital filing cabinets.

For learners, merely "saving" information is only the start. Real learning requires active processing. We observed that the typical study workflow was ~80% tedious prep:

  • retyping notes from the board or paper notebooks into a computer,

  • manually extracting definitions and turning them into flashcards (e.g. in Anki),

  • writing your own quiz questions just to practice before an exam.

Problem: The tools we used forced us to jump across four different apps before we could even start learning.

The core idea behind Groupnote boiled down to one question:

What if AI could turn your static notes into a complete, interactive learning environment?

Instead of bolting on a chatbot, we treated AI as a native engine for the whole product. We wanted to flip the ratio - the system would absorb ~80% of prep work so students could focus on actually learning.

We designed a workflow that removed friction end to end:

  1. Low-friction digitization (OCR to blocks): Snap a photo of a handwritten page. Through AI plus S3 buckets, we not only extract text but map it into our polymorphic block model - headings, lists, even math.

    Import modal: image/PDF upload, OCR path into the native block tree (Images vs PDF tabs).
  2. Note completion: While typing, AI suggests the next span - a GitHub Copilot-style loop for notes. Accept with Tab or keep typing to reject.

    Smart Completions: inline ghost text, Tab to accept - same muscle memory as IDEs, grounded in note context.
  3. In-editor help: Highlight a fragment and run Explain or Correct - no tab switching, no second context.

    Selection menu: Lernie Explain and Correct against math blocks - inline, in-place.
  4. Active learning in one click: Finish a topic and press one button. The system reads your note context and generates flashcard decks, a full quiz, or open-ended questions.

  5. Shared knowledge (multiplayer): All of this runs in real time inside a group space where classmates collaborate on the same document.

    Product shell: group context, module tabs (Dashboard, Notes, Quizzes, Lernie), shared editor - the multiplayer layer on top of sync.

Groupnote was not meant to be where you "store" notes. It was meant to be where you "learn from" notes.

Import path: from a photo of handwritten notes straight into the editor's native block graph.

Select text and immediately reach AI (Explain, Correct, Flashcards) - zero context switching.

03 - Product & Features

Product Features: The Learning Engine

The core: block-based editor

The heart of Groupnote was a first-party block editor (inspired by Notion). We refused to ship a plain textarea or a blob of raw HTML.

Every note element was an independent block stored in PostgreSQL as polymorphic jsonb. That unlocked:

  • Rich text with inline formatting,

  • Code blocks with language selection and syntax highlighting,

  • Math blocks with native equation rendering,

  • Image blocks backed by S3 buckets,

  • Lists, headings, and tables validated on the backend.

Block semantics let the server understand the document graph - essential for later feeding structured context into AI pipelines.

Ideal visual: editor canvas showing mixed block types (code + math).

One surface for text, code, math, and media - all typed in jsonb.

Product shell: study group, Notes / Quizzes / Flashcards tabs, page sidebar, math blocks in the editor, and Lernie in the header.

The editor also shipped an AI Writing Assistant with inline Smart Completions - accept with Tab without leaving the note.

Smart Completions: ghost text while typing, Tab to accept - IDE muscle memory inside the note.

Zero-friction digitization (AI OCR)

The worst friction in studying is often day one - retyping paper notes. We built a pipeline that removed it entirely.

Snap a photo of a handwritten page. Files land in S3, then models infer structure - not flat OCR text. Headings, lists, and even sketched equations map into native math blocks.

Pair with the import / OCR modal flow.

Handwriting becomes first-class blocks instead of a pasted wall of text.

Import modal: image/PDF upload, OCR, and projection into the block tree (Images vs PDF).

Once handwritten content becomes structured blocks, the next step is contextual AI support directly inside the reading and writing flow.

Context-aware AI (Explain & Correct)

We did not want learners opening a new ChatGPT tab for every confusion. AI lived inside the editor chrome.

Selecting text opened a popover with Explain for concepts and Correct for cleanup - always scoped to the passage in view, preserving flow.

Visual: selection + Lernie actions in place.

Hard ideas clarified without leaving the note.

The study factory (quizzes, flashcards, open questions)

This is where Groupnote became a real EdTech surface. Notes were inputs. One command analyzed the document and emitted active-learning artifacts:

  1. AI flashcards: decks from the densest definitions.

  2. AI quizzes: multiple-choice checks for fast validation.

  3. Open-ended sets: our deepest pedagogical bet - the model wrote prompts, graded prose answers, surfaced rubric-style feedback, and showed a model solution.

Static notes became interactive study kits in seconds.

"Lernie" - your AI tutor

When decks were not enough, learners opened a side panel and talked to Lernie with full-note context - no copy-paste. Tone presets (Casual, Academic, Professional) shaped whether answers felt like a lecturer or a study buddy.

Placeholder for a future sidebar chat capture.

Lernie keeps the entire note in working memory and matches the voice you pick.

Selection menu: Explain / Correct against math or prose - zero tab churn.

Real-time multiplayer

Study is social. Supabase Realtime let cohorts co-edit the same page, see cursors, and stream edits without reloads.

Group learning

Invites, shared libraries of notes/quizzes/flashcards/open sets, and subscription economics that could split across friends - same mental model as Spotify or Netflix family plans.

04 - System Overview

Technical Architecture: The engine room

Architectural thesis: pragmatism over hype

A system that combines collaborative editing, AI workflows, and group study can quickly fall into hype-driven development and split into fifteen services too early.

I chose a modular monolith for the core API, a dedicated AI microservice, and direct realtime delegation to the data layer. That gave us fast iteration with clean code boundaries and an upgrade path for scaling.

High-level architecture: React 18 -> ASP.NET Core 9 API -> domain modules, PostgreSQL/Supabase + Redis, with AI orchestration through Python service -> Gemini.

The diagram captures independent responsibilities per layer while keeping contracts explicit across HTTP and data boundaries.

System-layer stack

Backend (.NET 9) with vertical slices and CQRS

The core API runs on ASP.NET Core 9. Instead of classic horizontal layering, the monolith is organized into vertical slices and domain modules: Users, Groups, Notes, Quizzes, Tutor, AI, RecentActivity.

  • CQRS + MediatR: explicit command and query flows per use-case.

  • Validation + rate limiting pipelines: guardrails execute before handlers.

  • DDD boundaries: each module keeps its own entities, errors, and contracts.

csharp
public sealed record CreateQuizCommand(Guid NoteId, Guid UserId)
  : IRequest<CreateQuizResponse>;

public sealed class CreateQuizCommandHandler
  : IRequestHandler<CreateQuizCommand, CreateQuizResponse>
{
  public async Task<CreateQuizResponse> Handle(
    CreateQuizCommand command,
    CancellationToken ct)
  {
    // validation + plan limits run in MediatR pipeline
    var note = await notesRepository.GetAsync(command.NoteId, ct);
    var quiz = await aiOrchestrator.GenerateQuizAsync(note, ct);
    return await quizzesRepository.SaveAsync(quiz, command.UserId, ct);
  }
}

AI orchestration pattern (.NET -> Python)

The most important decision was keeping LLM SDK code out of .NET. The AI module acts as an orchestrator and uses IHttpClientFactory to talk to a dedicated Python service.

Python handles Gemini model calls, prompt shaping, OCR, and response normalization to JSON. .NET remains the system of record for authorization, billing, limits, and persistence.

yaml
orchestration:
  entrypoint: dotnet_ai_module
  transport: http_internal
  python_service:
    endpoint: http://python-ai:4000
    models:
      ocr: gemini-1.5-pro
      tutor_chat: gemini-1.5-flash
  response_contract:
    format: json
    persisted_by: aspnet_api

Infrastructure and local development

Local infrastructure was composed through .NET Aspire (AppHost) and Docker, so API, PostgreSQL, Redis, and Python AI service could boot together with one command.

That gave every developer the same runtime behavior and a more predictable path from local setup to test environments.

05 - Notes Engine

Notes Engine: Building a polymorphic block system on PostgreSQL jsonb

This section is the core technical payload of the case study. Groupnote notes are powered by independent blocks instead of one long HTML blob.

Document model: NoteBlock as the base with concrete block types (Text, Code, Math, List, Image) mapped into one structured payload.

Why HTML/Markdown as TEXT is the wrong abstraction

A classic WYSIWYG + HTML-in-TEXT works for simple publishing, but it fails quickly for realtime collaboration and AI-first workflows.

  • Nested HTML parsing wastes tokens and increases model error risk.

  • Document semantics (headings, code, math, media) collapse into one opaque string.

  • Partial realtime updates become conflict-heavy at group scale.

We switched to a block-based model inspired by Notion, where each unit carries its own ID and type.

Domain model in C#

The backend uses a polymorphic hierarchy so we can add block types without redesigning the entire domain.

NoteBlock.cs
// Base class for every editor element
public abstract class NoteBlock
{
    public string Id { get; set; } = Guid.NewGuid().ToString();

    [JsonPropertyName("type")]
    public string Type { get; set; } = string.Empty;

    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }
}

public sealed class TextBlock : NoteBlock
{
    public TextBlock() => Type = "text";
    public string Content { get; set; } = string.Empty;
}

public sealed class CodeBlock : NoteBlock
{
    public CodeBlock() => Type = "code";
    public string Language { get; set; } = "plaintext";
    public string Code { get; set; } = string.Empty;
}
Note.cs
public sealed class Note
{
    public Guid Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public List<NoteBlock> Content { get; set; } = new();
    public Guid UserId { get; set; }
    public Guid GroupId { get; set; }
}
UI implementation of the block model: granular block editing (Text/Heading/Code/Math) instead of full-document replacement.

Persistence: PostgreSQL jsonb + EF Core

Separate relational tables per block type would be over-engineering for this stage. We used jsonb with EF Core + Npgsql conversion hooks.

NoteConfiguration.cs
// NoteConfiguration.cs
builder.Property(x => x.Content)
    .HasColumnType("jsonb")
    .IsRequired()
    .HasConversion(
        v => JsonSerializer.Serialize(v, serializerOptions),
        v => JsonSerializer.Deserialize<List<NoteBlock>>(v, serializerOptions)
             ?? new List<NoteBlock>()
    );

Polymorphic deserialization

Correct type mapping from JSON to C# classes is critical. We used JsonTypeInfoResolver so `code` materializes as `CodeBlock`, while `text` maps to `TextBlock`.

note-content.json
[
  {
    "id": "blk-123",
    "type": "heading1",
    "content": "CPU architecture"
  },
  {
    "id": "blk-456",
    "type": "text",
    "content": "Processors are built from registers and an ALU."
  },
  {
    "id": "blk-789",
    "type": "image",
    "url": "https://storage.supabase.co/...",
    "description": "ALU block diagram"
  }
]

Why this unlocked the product

  • AI context parsing: backend sends predictable JSON so models can focus on relevant block types.

  • Realtime sync: frontend updates a specific block instead of overwriting a full document, which reduces group editing conflicts.

06 - Realtime & Sync

Real-time System: Scaling by bypassing the backend

This section covers a key architectural move: realtime traffic bypasses the .NET API, so the backend stays stateless and focused on business logic.

Classic trap: why not default to SignalR

The default .NET instinct for collaborative editing is SignalR. In this MVP, that would force the API to hold state for high-frequency keystroke events.

That quickly turns into a memory bottleneck and expensive horizontal scaling. We intentionally removed realtime load from the C# API.

Direct-to-database architecture

Instead of a custom event broker, clients subscribe directly to postgres_changes via Supabase Realtime. From the API perspective, realtime synchronization does not exist.

Realtime flow: local edit in client, debounced UPDATE to Supabase/PostgreSQL, postgres_changes trigger, and immediate broadcast to all subscribers.

Practical flow: frontend writes and debouncing

Debounce protects the database from per-keystroke writes while keeping local typing feedback instant.

  1. Learner edits a TextBlock and sees local state update immediately.

  2. After 1 second of inactivity, frontend sends a single UPDATE to the note row.

  3. Supabase detects the DB change and broadcasts it to every subscriber.

subscribeToNoteChanges.ts
const channel = supabase
  .channel(`note_${noteId}`)
  .on(
    "postgres_changes",
    {
      event: "*",
      schema: "public",
      table: "Notes",
      filter: `Id=eq.${noteId}`,
    },
    (payload) => {
      if (payload.new) {
        updateLocalState(payload.new.Content);
      }
    }
  )
  .subscribe();
persistNoteDebounced.ts
const persistNote = debounce(async (noteId: string, content: NoteBlock[]) => {
  await supabase
    .from("Notes")
    .update({ Content: content, UpdatedAt: new Date().toISOString() })
    .eq("Id", noteId);
}, 1000);

const presence = supabase.channel(`presence_note_${noteId}`, {
  config: { presence: { key: userId } },
});
Realtime in practice: note content and collaborator presence update without page refresh.

Single Source of Truth

The biggest win is data consistency. Frontend and backend write to the same Notes table and Supabase pushes changes automatically.

Clients do not need to know whether an update came from another learner or from an async AI process such as OCR.

Net result of the decision

  • Lower latency - no extra hop through the app server.

  • Lower infra cost - .NET API stays lean and business-focused.

  • More stable multiplayer behavior out of the box.

07 - AI Layer

AI System: Orchestration, RAG, and AI Copilot

AI pipeline: .NET as orchestrator, Python as AI engine, Gemini as the inference layer.

Separation of concerns: .NET orchestrator, Python AI engine

Groupnote was designed as AI-native without embedding LLM SDK complexity directly in ASP.NET Core controllers.

  • ASP.NET Core monolith: system of record, authorization, quotas, business logic, persistence.

  • Python microservice: prompt shaping, Gemini calls, structured JSON responses.

Orchestration flow (IHttpClientFactory + MediatR)

  1. Frontend sends a generation request to .NET API.

  2. Backend checks plan quotas in Redis.

  3. Handler collects note context (jsonb) and delegates to Python over HTTP.

  4. Python returns typed DTO payload and .NET persists the output.

GenerateQuizCommandHandler.cs
public async Task<Result<QuizDto>> Handle(
    GenerateQuizCommand command,
    CancellationToken ct)
{
    var limits = await limitsService.GetForUserAsync(command.UserId, ct);
    if (!limits.CanGenerateQuiz) return Result.Fail(AiErrors.LimitExceeded);

    var note = await notesRepository.GetAsync(command.NoteId, ct);
    var request = QuizGenerationRequest.From(note.Content);

    var dto = await aiHttpClient.PostAsJsonAsync<QuizDto>(
        "/api/quiz/generate",
        request,
        ct);

    await quizzesRepository.SaveAsync(dto, command.UserId, ct);
    return Result.Ok(dto);
}
Quiz generation flow: .NET orchestrates limits and context while Python returns generated questions.
Open questions flow: AI evaluates free-form answers and returns score + feedback.
Flashcards pipeline: fast extraction of key facts into a ready study deck.

AI Copilot and cost guardrails (HybridCache + cooldown)

Note completion is high-frequency and can burn budget quickly, so backend enforces strict cooldown logic before any paid model call.

NoteCompletionCooldownService.cs
var key = $"ai:completion:cooldown:{userId}";
var lastCall = await hybridCache.GetOrCreateAsync<DateTime?>(
    key,
    _ => ValueTask.FromResult<DateTime?>(null),
    token: ct);

if (lastCall is not null && DateTime.UtcNow - lastCall < TimeSpan.FromSeconds(30))
{
    return Result.Fail(NoteErrors.CooldownActive);
}

await hybridCache.SetAsync(key, DateTime.UtcNow, token: ct);
AI Copilot in-editor: contextual completion based on the current note fragment.

Tutor memories and personalization via RAG

Lernie gets both note context and user preferences (with consent), which lets responses adapt to tone and learning style.

TutorMemoryContext.json
{
  "userId": "usr_123",
  "memoryContext": [
    "User prefers game-based examples",
    "User struggles with memorizing dates"
  ],
  "tone": "Academic",
  "noteContext": "..."
}
Core memories panel: user-controlled preference context injected into tutor requests.

One AI layer, multiple product capabilities

  • Vision/OCR: photo-to-block note conversion.

  • Generative: quizzes, flashcards, open questions.

  • Contextual: Explain/Correct and inline completion.

08 - Monetization

Monetization & Cost Control

The multiplayer growth strategy

Building a strong product is one part. In EdTech, finding a scalable business model with price-sensitive users is the harder part.

Groupnote was designed around groups from day one. Instead of strict per-user billing, we used a family-plan style dynamic where one payer can unlock premium value for a shared workspace.

That unlocks a compounding network effect: users invite their peers to maximize shared plan value.

Usage-based pricing and hard AI limits

Model inference costs can spike fast, so unlimited AI at low price points is not viable. We defined Free, Pro, and Ultra tiers with strict usage caps.

Pricing as architecture: value and limits are encoded before requests touch paid model endpoints.

In the data model, each plan exposes explicit budget controls:

SubscriptionPlan.cs
public sealed class SubscriptionPlan
{
    public string Name { get; set; } = string.Empty;
    public int AiQuizGeneratedLimit { get; set; }
    public int AiFlashcardsGeneratedLimit { get; set; }
    public int AiTutorMessagesLimit { get; set; }
    public int GroupCountLimit { get; set; }
}

Backend rate-limit enforcement via pipeline

Monetization only works if quotas are enforced before requests hit the AI layer. In Groupnote, that guard lived in MediatR via an IRateLimited contract.

RateLimitBehavior.cs
public async Task<TResponse> Handle(
    TRequest request,
    RequestHandlerDelegate<TResponse> next,
    CancellationToken ct)
{
    if (request is not IRateLimited limited) return await next();

    var usage = await usageTracker.GetMonthlyUsageAsync(limited.UserId, ct);
    var plan = await plansService.GetCurrentPlanAsync(limited.UserId, ct);

    if (usage.AiQuizGenerated >= plan.AiQuizGeneratedLimit)
        throw new LimitReachedException("AiQuizGeneratedLimit");

    return await next();
}

Stripe integration end-to-end

  1. Frontend starts purchase flow, backend creates Stripe Checkout Session.

  2. Stripe webhook notifies backend on payment/subscription events.

  3. Backend verifies webhook signature, updates UserPlans, and refreshes cache.

StripeWebhookController.cs
var stripeEvent = EventUtility.ConstructEvent(
    json,
    request.Headers["Stripe-Signature"],
    stripeOptions.WebhookSecret);

if (stripeEvent.Type == "checkout.session.completed")
{
    var session = stripeEvent.Data.Object as Session;
    await plansService.ActivatePlanAsync(session!.CustomerId, ct);
    await usageCache.InvalidateAsync(session.CustomerId, ct);
}

The outcome: real billing readiness from day one with an automated paid-user onboarding path.

09 - What went wrong?

What went wrong: The "just one more feature" trap

Engineering win, product failure

Technically, Groupnote worked. The architecture held, realtime stayed stable, and the AI layer handled complex workflows.

Product-wise, it failed to launch because I kept building in isolation before validating a true MVP.

It looked launch-ready on the surface - polished onboarding and complete auth flow.

Original MVP vs what I actually built

The initial plan was simple: shared notes and one AI quiz button. That was enough to validate demand with real students.

Instead, the architecture made adding modules so easy that scope kept expanding.

What shipped in code instead of MVP:

  • full block editor with LaTeX, code, and media,

  • Lernie tutor with memory and early RAG,

  • OCR import pipeline,

  • flashcards and open-question generators,

  • subscription logic, AI usage tracking, and group invitations.

The dashboard looked mature and feature-rich - but that still did not replace market validation.

Founder trap and illusion of progress

Every new module felt like momentum. In reality, it was textbook Scope Creep: emotionally rewarding, product-risky.

  1. “Just flashcards”.

  2. “Just open questions”.

  3. “Just OCR”.

[ moment of truth ]

The codebase kept growing, but I moved further away from the only test that matters - real users in a real market.

The weight of my own ambition

At some point, operating the ecosystem cost more energy than product learning. I built a factory before validating the first skateboard.

10 - Key Takeaways

Key Takeaways: What Groupnote taught me

If I compress hundreds of hours spent on this project into a few lessons, these are the ones I keep using.

01

Product discipline beats scope expansion

A real MVP should feel small, shippable, and optimized for market learning.

02

Pragmatic architecture beats hype

Splitting ASP.NET Core and Python gave a stable System of Record plus a flexible AI layer.

03

Infrastructure leverage compounds delivery

Leaning on Supabase Realtime and PostgreSQL reduced operational drag and increased product velocity.

04

Mindset shift: from code output to user value

Validate demand first, then scale architecture. Shipping > polish. The best code is the one that solves a real user problem.

11 - Final Reflection

Final reflection

Groupnote never became a market success and it never left the development environment publicly.

Even so, I do not regret the work. This project became my proving ground for distributed architecture, realtime collaboration, and AI-native product design.

I stopped thinking like a programmer. I started thinking like a product engineer.

[ moment of truth ]

Validate the need first, scale the solution second. Without that, even the best architecture becomes weight.

Today, my first question is no longer "Which architecture should I use?" but "What is the smallest thing I can ship to verify real demand?".

You cannot copy this lesson from a tutorial - you have to build your own Death Star.