DEV Community

Cover image for Building a Production LMS: Architecture, Payments, and Content Protection
Akshay Kumar
Akshay Kumar

Posted on Originally published at tensorik.in

Building a Production LMS: Architecture, Payments, and Content Protection

Building a Production LMS: Architecture, Payments, and Content Protection

A learning management system looks straightforward from the outside. Students browse courses, make payments, watch lessons, and attend live classes.

But building one for a real education business quickly introduces problems that aren't visible in the UI.

What happens when a payment succeeds but the enrolment isn't activated? How do you prevent a purchased course from becoming a shared login across multiple devices? How should a mobile app and an admin console enforce the same business rules? And what happens when payment notifications arrive more than once or a request fails halfway through a workflow?

I worked on these challenges while building the MBT Institute LMS, a production learning platform with a Flutter Android app, a NestJS backend, and a Next.js administration console.

In this article, I want to walk through the architecture, explain some of the engineering decisions behind it, and share lessons that may help if you're building an LMS or another platform where payments control access to digital content.

The interesting part wasn't simply choosing a tech stack. It was making sure authentication, payments, enrolments, device access, and content delivery behaved consistently as parts of one system.

1. The architecture: three applications, one backend

I chose to separate the platform into three independently deployable applications:

  • Flutter: the Android application for learners.
  • NestJS: the backend API responsible for business logic and integrations.
  • Next.js: the administration console for operational workflows.

Supabase PostgreSQL provides the relational data layer, with supporting services for authentication, payments, media delivery, notifications, live classes, and background processing.

Here's a simplified view of how the components fit together:

This is a logical architecture rather than an exhaustive deployment diagram. It illustrates the important boundary: both client applications use the same backend rules instead of implementing separate versions of payment validation and course-access logic.

Why not put everything in the frontend?

Because a frontend is not a security boundary.

A user can modify a client, replay requests, or call an API directly. Hiding a button or checking a condition in Flutter doesn't prevent someone from bypassing that condition.

The backend must remain authoritative for payment verification, enrolment eligibility, device authorization, and access to protected resources.

2. Designing the data model around business rules

One of the most important decisions is how to represent the relationships between learners, purchases, and course access.

At a conceptual level, I'd start with entities such as:

  • users and profiles
  • courses, sections, and lessons
  • orders and order_items
  • payments
  • enrollments
  • devices and device_transfer_requests
  • invoices
  • live_sessions and recordings

An order represents what a learner intends to purchase. A payment records the provider transaction. An enrolment represents the learner's entitlement to a course.

These concepts are related, but they aren't interchangeable.

If a student says, "I paid, but I can't access the course," you need to investigate the order, payment status, enrolment, and device association independently.

That becomes difficult if course access is activated simply because the frontend redirects to a success page.

My takeaway: model the business entities and their relationships before building the checkout UI.

3. Payment processing: success isn't just a redirect

Consider a typical checkout flow:

  1. The learner selects a course and initiates checkout.
  2. The backend validates the course, price, applicable commercial rules, and coupon.
  3. The backend creates an order associated with the relevant payment workflow.
  4. The learner completes checkout through Razorpay.
  5. The backend verifies the payment using trusted provider information.
  6. The verified payment is recorded, and the appropriate enrolment is activated.
  7. The corresponding invoice becomes available through the billing workflow.

The frontend redirect must never be the authority for activating an enrolment.

A request can time out. A learner can close the app. A payment notification can arrive more than once. A server can fail after updating one record but before completing another operation.

This is where idempotency becomes essential.

Processing the same verified payment event repeatedly should not create duplicate enrolments or invoices. Database constraints and transactions should protect critical relationships, while asynchronous work needs a reliable handoff and retry strategy.

For example, a payment event can be received twice because of a provider retry. Your system should recognize that it has already processed the relevant transaction rather than blindly repeating every side effect.

The implementation details depend on the provider's event semantics and your transaction boundaries, but the principle is consistent: payment verification and entitlement activation must form an explicit, recoverable workflow.

Questions worth answering before shipping checkout

  • What happens if payment succeeds but the client times out?
  • What happens if the same webhook is delivered twice?
  • How do cancellations and refunds affect enrolment?
  • How can an administrator reconcile a completed payment with a missing invoice?
  • Can the system safely retry a partially completed workflow?

These aren't edge cases to consider after launch. They're part of the payment lifecycle.

4. Content protection: authentication is not entitlement

Content protection was another central requirement.

A successful login tells us that a user has authenticated. It does not automatically mean that the user is entitled to every course or that the current device is authorized to access a particular enrolment.

I approached this as a layered problem.

Device-bound access

Each enrolment is associated with one active device. A device identifier is maintained using secure storage, and the backend validates the association when access is requested.

Signing in on a second phone does not automatically grant that device access to the same enrolment.

The critical part is server-side enforcement. A device check that exists only inside the mobile app can potentially be bypassed by modifying the client or calling the API directly.

A device identifier also isn't cryptographic proof of identity. The backend still needs to validate the authenticated session, course entitlement, and device association rather than blindly trusting an identifier supplied by the client.

Controlled device transfers

Students replace phones and lose devices. A strict device lock without a recovery process would turn a reasonable security control into a support problem.

The platform includes an administrator-reviewed device-transfer workflow so legitimate device changes can be handled without treating every new device as automatically authorized.

This is a useful general principle:

Every restrictive security control needs a legitimate recovery path.

Protected media delivery

Protected media and resources should be delivered through authorized, short-lived URLs rather than intentionally exposed public links. Playback surfaces can also use platform-supported screenshot restrictions where appropriate.

These controls reduce casual copying and limit the usefulness of leaked links, but they cannot make piracy impossible. Someone can record a screen using another device, and sufficiently determined attackers may find other ways to reproduce content.

The goal is layered protection: authenticate the learner, verify the entitlement, validate the device, and restrict access to the media itself.

5. Where PostgreSQL RLS fits

Supabase Row Level Security and backend authorization solve related but different problems.

RLS can restrict which database rows a user or role can access. Application-level authorization can enforce domain-specific rules, such as whether a particular enrolment grants access to a particular lesson.

Both layers need deliberate design.

For a similar project, I'd review every table exposed to client access, enable appropriate RLS policies, and ensure privileged server credentials never reach the mobile application or browser.

Privileged database functions and backend operations should also be narrowly scoped and validate their inputs.

A few things I'd pay particular attention to:

  • Don't assume hiding a UI element provides authorization.
  • Don't expose service-role credentials in a client application.
  • Keep database policies consistent with the intended access model.
  • Test unauthorized reads and writes, not only successful user journeys.
  • Review privileged functions for overly broad permissions.

RLS is powerful, but it doesn't replace application security reviews or a clear authorization model.

6. Separating commercial rules

The platform supports hardware and software course categories with different commercial rules. Their payment configurations, coupon applicability, and reporting requirements need to remain consistent with those distinctions.

Coupons are validated by the backend before discounts are applied, and purchases maintain transaction-level payment and enrolment records.

The important design point is that pricing and eligibility must not be trusted to the client. A modified request should not be able to change the price, apply an ineligible coupon, or claim access to a course without a verified transaction or other valid entitlement.

I'd define these rules before building the checkout interface. Retrofitting them later is much harder once multiple clients, reports, and administrative workflows depend on the same data.

7. Live classes, recordings, and background jobs

The platform also integrates Zoom for live-class scheduling and supports publishing recordings back into the course experience.

Redis and BullMQ provide a foundation for asynchronous processing where it makes sense. Notification delivery and other retryable work can be separated from the user-facing request that initiated it.

Queues can improve responsiveness, but they introduce operational responsibilities.

Jobs need appropriate retry policies. Failures need visibility. Operations with side effects need to be safe to execute more than once.

A queue alone doesn't make a workflow reliable. Reliability comes from durable state, safe retries, observability, and a clear recovery strategy.

I'd avoid introducing background processing for everything. Start with the workflows that benefit from asynchronous execution, then make their failure and retry behavior explicit.

8. Production is where the architecture gets tested

The Android app launched on Google Play in October 2026. After launch, the work shifted from getting the product shipped to understanding how it behaved under real usage.

Production monitoring matters because several different signals tell different stories:

  • Downloads indicate distribution, not necessarily active learners.
  • Payment records show transactions, not automatically recognized revenue or profit.
  • API request counts show traffic, not user engagement.
  • Response errors and latency help reveal reliability and performance problems.

During the production review, we examined Supabase API Gateway traffic and response errors alongside backend request metrics. Those numbers came from different systems and reporting windows, so they needed to be interpreted separately rather than combined into a single headline metric.

The operational lesson is that launch is only one milestone. Monitoring, capacity planning, resource alerts, backups, restore testing, and incident response are all part of running a production system.

If I were preparing a similar platform for launch, I'd want to know:

  1. Can I identify failed payment-to-enrolment workflows?
  2. Can I trace a learner's access decision across the relevant records?
  3. Do I have alerts for elevated error rates and latency?
  4. Have I tested restoring a backup, not just creating one?
  5. Can an administrator resolve common operational issues without direct database edits?

These capabilities become increasingly important as the platform grows.

9. If I were building an LMS from scratch today

I'd approach the work in this order.

1. Define the domain model. Establish the relationships between users, courses, orders, payments, enrolments, and devices.

2. Implement authentication and authorization. Define learner and administrator roles, course-entitlement checks, and RLS policies.

3. Build the payment lifecycle. Add server-side price validation, provider verification, idempotent processing, enrolment activation, and reconciliation.

4. Protect content access. Implement device binding, transfer workflows, and authorized media delivery.

5. Build the administration console. Give staff the tools to manage courses, billing, device transfers, and support.

6. Add integrations. Introduce live classes, notifications, and background jobs where they solve a concrete problem.

7. Prepare for production. Add logging, metrics, alerts, backups, restore tests, and load testing.

You don't need every integration on day one. A smaller system with correct payment and access semantics is a better foundation than a feature-rich application with inconsistent business rules.

10. What I'd take into the next project

A few lessons stood out.

Security belongs in the data model. Device associations, enrolments, and transfer workflows are closely connected, so they should be designed together.

Important rules need a clear authority. The mobile app and admin console shouldn't independently implement payment validation or course entitlement.

Operational workflows are part of the product. Payment reconciliation, device recovery, and support tools determine whether a platform is manageable in production.

Observability should come before a traffic spike. Metrics and alerts are most useful when you understand them before an incident.

Security has to account for legitimate users. A restrictive system without a practical recovery path creates unnecessary friction for the people it's supposed to protect.

These lessons apply beyond education technology. They're relevant to subscription products, digital content platforms, training applications, and any system where a transaction controls access to a protected resource.

Final thoughts

The most meaningful engineering work often happens behind the screens people see.

A course page is only one part of the product. Behind it are questions about identity, payment verification, enrolment state, device access, media delivery, retries, and operational recovery.

Building the MBT Institute LMS reinforced something I believe applies to most production systems: the difficult part isn't making individual components work. It's defining the boundaries between them and ensuring those boundaries remain correct when requests fail, users change devices, or external services behave unexpectedly.

I'm interested in how other developers approach these problems, particularly idempotent payment processing, device-bound access, and protecting paid digital content.

If you've built something similar, what was the hardest production problem you had to solve?


Explore the case study: Building a Piracy-Resistant LMS — MBT Institute

Try the app: MBT Institute LMS on Google Play

Top comments (1)

Collapse
 
wbcomdesigns profile image
Wbcom Designs 💡 •

For us it was the gap between "payment succeeded" and "student can open the lesson". Every support ticket lands right there, so we treat enrolment as something you can safely retry, not a one-shot step. Full disclosure: we build an LMS (Learnomy) at Wbcom, and shared logins were our second-biggest headache too.