RZ

Preparing your experience

Back to Blog
System Architecture

Adding Live Classes and Paid Courses to an LMS Without Breaking the Grading Engine

January 18, 20258 min read
LaravelgRPCPaymentsArchitectureEdTech

Our Laravel LMS was already handling course content, enrollment, and auto-graded assessments for 5,000-plus students when the business asked for two significant additions: paid premium courses with region-specific payment providers, and live virtual classrooms. Both requests sound like ordinary feature work in isolation. Together, they're exactly the kind of addition that, bolted on carelessly, destabilizes a grading and progress-tracking engine that had been quietly stable for years — and "quietly stable" is a property that's very easy to break and very hard to earn back.

Payments: One Interface, Two Providers

Different regions the platform serves needed different payment rails — Stripe worked well in some markets, PayPal was the practical choice in others, largely driven by local payment-method preferences and processor availability rather than any technical preference on our part.

The naive approach would have let the course-access-gating logic — the code that decides "has this student paid for this course, and should they see its content" — branch directly on which provider processed the payment. That's a tempting shortcut, and it's also exactly the kind of decision that quietly multiplies complexity every time a new provider gets added, because the gating logic itself becomes coupled to provider-specific details it has no real business knowing about.

Instead, we put both providers behind a single internal payments interface. The access-control code that gates premium content simply asks "has this student paid," through one consistent interface, and has no visibility into which provider actually processed the transaction.

interface PaymentProvider {
    public function charge(User $user, Course $course): PaymentResult;
    public function verifyWebhook(Request $request): bool;
}

class StripeProvider implements PaymentProvider { /* ... */ }
class PayPalProvider implements PaymentProvider { /* ... */ }

// Access-gating logic never touches provider specifics:
class CourseAccessService
{
    public function hasPaidAccess(User $user, Course $course): bool
    {
        return Payment::where('user_id', $user->id)
            ->where('course_id', $course->id)
            ->where('status', 'completed')
            ->exists();
    }
}

Adding a third provider later — which did in fact come up in a subsequent conversation with the business about a new region — is a matter of implementing one interface, not touching gating logic anywhere in the codebase. That's the entire point of the abstraction: the cost of adding a provider stays constant, rather than growing with the number of providers already integrated.

Live Classes as a Separate Service

This was the bigger risk of the two additions, by a wide margin. Live virtual classrooms need low-latency session-state synchronization — a student joining a session, leaving it, or losing connection needs to be reflected in attendance records close to real time, because attendance data feeds directly into the same progress-tracking system the grading engine depends on.

Bolting that requirement directly into the core LMS would have meant touching code paths the grading engine also relies on — attendance records, progress state, enrollment status — all in service of a brand-new feature with fundamentally different latency and reliability characteristics than anything the LMS had handled before. That's a lot of new risk concentrated exactly where we could least afford instability.

The stability of the grading engine wasn't something we were willing to gamble for the sake of shipping live classes faster. Isolating the new, higher-risk feature from the old, proven one was the whole strategy.

Instead, we built live classes as an entirely separate service, communicating with the core LMS over gRPC rather than REST. The choice of gRPC specifically, rather than the simpler and more familiar JSON-over-HTTP, came down to the lower latency and native streaming support gRPC's binary protocol provides — both of which matter directly for keeping session-state sync fast enough that attendance marking feels reliable in real time, not delayed or occasionally dropped.

Why gRPC Specifically

Requirement REST/JSON gRPC
Payload size for high-frequency session events Larger (text-based) Smaller (binary)
Native streaming support Requires workarounds Built in
Latency for session-state sync Higher Lower

De-Risking the Rollout

Even with strong architectural isolation between live classes and the core LMS, we didn't launch live classes everywhere at once. The rollout was feature-flagged per institution, so if something went wrong with live classes at one institution — a bug, an unexpected load pattern, an integration issue specific to that institution's setup — it wouldn't affect the other institutions' 5,000-plus students on the platform.

This mattered more than it might seem, because "isolated architecturally" and "isolated operationally" are two different guarantees. Good architecture reduces the blast radius of a bug reaching the wrong system. A staged, per-institution rollout reduces the blast radius of a bug reaching the wrong users, even within the same system. We wanted both.

Best Practices

  1. Put varying implementations (multiple payment providers, in this case) behind a single stable interface, so the cost of adding a new one doesn't grow with how many already exist.
  2. Isolate a new, higher-risk feature into its own service when it touches shared, business-critical state — don't let a new feature's instability become the stable system's problem.
  3. Choose your inter-service protocol based on the actual latency and throughput requirements of the feature, not on familiarity alone — gRPC was the right call here specifically because of the session-sync latency requirement.
  4. Feature-flag significant rollouts at the granularity that matches your actual blast-radius concern — per institution here, because institutions are effectively isolated tenants of the platform.

Common Mistakes

  • Letting business logic (like course-access gating) branch on infrastructure details (like which payment provider was used) instead of hiding those details behind an interface.
  • Adding a high-risk, high-novelty feature directly into a stable core system's codebase, rather than isolating it as a separate service with a clear boundary.
  • Choosing a communication protocol based on team familiarity rather than the feature's actual latency and throughput needs.
  • Launching a significant new feature to an entire user base at once, rather than staging the rollout to limit exposure while confidence is still being built.

Security Considerations

Payment provider credentials for both Stripe and PayPal are stored separately, scoped to only the payments service, and never exposed to the course-access-gating code that merely checks payment status. Webhook endpoints for both providers verify signatures before trusting any payment-status update, since an unverified webhook is effectively an unauthenticated request claiming financial state changed. The gRPC channel between the live-classroom service and the core LMS is authenticated and encrypted, given that it carries attendance data tied to individual students.

FAQ

Q: Why not use a message queue between the live-classroom service and the core LMS instead of gRPC?
A queue is a good fit for asynchronous, eventually-consistent updates, but session-state sync for live attendance needed something closer to synchronous, low-latency request/response — a student's "joined" event needs to register quickly enough that a class roster feels live, not eventually accurate.

Q: Did isolating live classes as a separate service slow down development compared to building it directly into the LMS?
Initially, slightly — defining the gRPC contract took more upfront design work than just adding routes to the existing LMS would have. That upfront cost was worth it given what was at stake: zero regressions in a grading engine serving 5,000-plus students is a much better trade than shipping marginally faster and risking that stability.

Testing the Payment Abstraction Under Real Failure Modes

An interface that hides provider differences is only as good as its handling of the ways providers actually fail, and Stripe and PayPal fail differently in practice — different webhook retry behaviors, different edge cases around partial refunds, different timing on payment confirmation. We built a shared contract test suite that both provider implementations have to pass, covering not just the happy path but specific failure scenarios: a webhook arriving out of order, a payment that succeeds on the provider's side but whose confirmation webhook is delayed, and a refund initiated directly through a provider's dashboard rather than through our own API.

This contract test suite caught a real bug before launch: our initial PayPal implementation assumed webhook delivery order matched event order, which held true in testing but isn't actually guaranteed by PayPal's own documentation. Because course access is gated on payment status, an out-of-order webhook could have briefly granted or revoked access incorrectly. The fix was straightforward once identified — a state machine that only accepts specific status transitions and ignores webhooks that would represent an invalid transition — but it's the kind of bug that's very easy to miss without deliberately testing for out-of-order delivery.

What the Isolation Boundary Actually Prevented

It's worth being concrete about what the live-classroom service's isolation from the core LMS actually bought us, beyond the abstract architectural argument. During the initial institution-by-institution rollout, the live-classroom service experienced a genuine incident — a memory leak under sustained load during a particularly large simultaneous session at one institution. Because the service was isolated, this incident degraded live-classroom functionality at that one institution for roughly twenty minutes while we redeployed the affected service. Grading, enrollment, and progress tracking for all 5,000-plus students across every institution were completely unaffected, because those systems had no dependency on the live-classroom service's health at all. Had live classes been built directly into the core LMS, the same memory leak would have been a much broader incident, and a much harder one to isolate and diagnose under pressure.

Conclusion

Paid premium courses shipped with dual payment providers and zero regressions in the existing grading engine. Live virtual classrooms launched as an isolated service without touching core LMS code paths at all. The platform kept serving 5,000-plus active students through the entire rollout with zero major incidents, and gRPC-based session sync kept classroom-to-enrollment latency low enough for real-time attendance marking to actually feel real-time. None of that was an accident — it was the direct result of treating architectural isolation as the primary risk-management tool for adding ambitious new features to a system that couldn't afford to become unstable.

Found this useful? Explore more articles on System Architecture.

All articles