Rails × DDD × AI でつくるスケールするLMS

English follows Japanese. 英語の文章は後半にあります。

こんにちはフンです。Ruby on Rails は「速くつくる」ことに圧倒的に強いフレームワークです。けれど、LMS(学習管理システム)のようにドメインが複雑に絡み合うプロダクトを長く育てていくと、その手軽さがそのまま足かせになっていきます。

弊社の WisdomBase を開発するなかで私たちがたどり着いた、ドメイン駆動設計とイベント駆動アーキテクチャ、そして AI 時代の設計思想について書いてみます。

Rails の scaffold から始まる開発体験は本当に気持ちがいいものです。モデルを作って、コントローラを生やして、ビューをつなげば、ものの数分で動く CRUD アプリができあがります。プロトタイプや初期フェーズでは、これ以上ない武器です。

でも、プロダクトが成長して扱う概念が増えていくと、状況は変わってきます。
とくに私たちが向き合っている WisdomBase は、コース・受講者・レクチャー・決済・修了証……と、複雑なドメインの集合体です。素朴な CRUD のまま走り続けると、どこかで必ず壁にぶつかります。この記事は、その壁をどう越えるかの話です。

LMS を CRUD だけで作ると何が起きるか

典型的な LMS には、コース、受講者、レッスン、決済、修了証といった概念が登場します。これを CRUD ベースの Rails アプリで素直に組み上げていくと、だいたい次のような状態に陥っていきます。

  • 神モデル(God Model):責務を抱えすぎた巨大な CourseUser
  • コールバック地獄after_saveafter_commit に積み上がっていく副作用
  • 散らばったビジネスルール:同じ業務知識がモデル・コントローラ・ジョブに点在する
  • 密結合:あるコンポーネントを直そうとすると、関係ないはずの機能が壊れる

たとえば「コースを修了する」という、たった一つのアクションを考えてみてください。これは進捗の更新、修了証の発行、通知メールの送信、分析データの記録……といった処理を芋づる式に呼び出します。
それらが Course#complete! の中で密に結びついていると、「修了証のロジックをちょっと変えたいだけ」なのに、通知や分析にまで影響が及びます。変更が常にリスクを伴う、あの感覚です。

# ありがちな「神モデル」── 修了処理がすべてここに集まってしまう
class Course < ApplicationRecord
  after_update :issue_certificate, if: :just_completed?
  after_update :send_completion_email, if: :just_completed?
  after_update :update_analytics, if: :just_completed?
  # ... 変更するたびに、ここのどれかが壊れないか祈ることになる
end

DDD で「境界」を引く

ドメイン駆動設計(DDD)の発想はシンプルです。システムを、責務のはっきりした独立したドメインに分割します。WisdomBase でいえば、こうなります。

  • Courses(コース)
  • Students(受講者)
  • Enrollments(受講登録)
  • Progress(進捗)
  • Payments(決済)
  • Certificates(修了証)

ポイントは、それぞれのドメインが自分のロジックを所有し、他のドメインとはイベントを通じてやり取りすることです。コードベースが「機能の塊」ではなく「業務の塊」で構成されるようになり、見通し・拡張性・保守性が一段あがります。新しく入ったメンバーが「決済まわりを触りたい」と思ったとき、見るべき場所が一目でわかる、というのは想像以上に効いてきます。

イベント駆動アーキテクチャ

ドメイン同士を直接メソッド呼び出しでつなぐのをやめて、イベントでつなぎます。ドメインは「何が起きたか」を宣言するだけで、その後始末を誰がするかは知らなくて構いません。

  • StudentEnrolled ── 受講者が登録した
  • CourseCompleted ── コースが修了された
  • PaymentCompleted ── 決済が完了した

先ほどの「コース修了」を、この形に置き換えるとこうなります。

  1. Progress ドメインが CourseCompleted イベントを発行する
  2. Certificates ドメインがそれを受け取り、修了証を発行する
  3. Notifications ドメインが同じイベントを受け取り、メールを送る
# 発行する側は「修了した」とだけ宣言する。あとは関知しない
class Progress::CompletionService
  def call(student:, course:)
    progress = record_completion(student, course)
    EventStore.publish(
      CourseCompleted.new(student_id: student.id, course_id: course.id)
    )
    progress
  end
end

# 受け取る側は、それぞれ独立して反応する
class Certificates::OnCourseCompleted
  def handle(event)
    Certificate.issue!(student_id: event.student_id, course_id: event.course_id)
  end
end

この設計がもたらすものは次のとおりです。

  • 疎結合:修了証の仕様を変えても、進捗や通知は一切影響を受けません
  • 機能拡張が楽:「修了したら Slack 通知も」と言われても、新しいハンドラを足すだけで済みます
  • システムの振る舞いが明確:「何が起きたら何が動くか」がイベント一覧で読めます

パフォーマンスのための Read Model

ドメインをきれいに分けると、今度はダッシュボードのような「横断的に集計する画面」が重くなりがちです。複雑な JOIN を毎回叩いていては表示が遅くなります。

ここで効くのが Read Model(読み取り専用モデル)です。あらかじめ計算済みのデータを専用テーブルに持っておきます。たとえば student_dashboards テーブルに、こんなカラムを用意しておきます。

  • 修了済みコース数
  • 進捗率
  • 最終アクティビティ日時

このテーブルはイベントを購読して更新されます。CourseCompleted が飛んできたら修了済みコース数をインクリメントする、といった具合です。書き込みモデル(正規化された真実)と読み込みモデル(表示に最適化された投影)を分離する、いわゆる CQRS の考え方です。これでクエリは速く、シンプルに保たれます。

ワークフローを束ねる Process Manager

受講登録のように、複数のドメインをまたいで進む業務フローもあります。

  1. 受講者が登録する(Enrollments)
  2. 決済が完了する(Payments)
  3. コースがアクティブになる(Courses)

この「順序のある業務手順」をどこに書くか。各ドメインに散らすと、また密結合に逆戻りしてしまいます。そこで Process Manager を一つ立てて、イベントを聞きながら次のアクションを起動させます。ワークフローのロジックが一箇所に集約され、流れを追いやすくなります。

class EnrollmentProcessManager
  def on(StudentEnrolled)  = request_payment
  def on(PaymentCompleted) = activate_course
  # 「登録 → 決済 → 有効化」の流れが、この1ファイルを読めばわかる
end

レガシーな Rails からどう移行するか

とはいえ、いきなり全部を書き換えるのは現実的ではありません。DDD は段階的に導入できます。私たちが踏んでいるステップはこうです。

  • domains/, read_models/, process_managers/ といったディレクトリを用意する
  • Event Store を導入する
  • まず Read Model から切り出す(既存ロジックを壊さず効果を出しやすい)
  • サービスからイベントを発行するようにする
  • コールバックと密結合を、少しずつイベント購読に置き換えていく

一気にやらないのがコツです。動いているものを壊さず、新しい境界を少しずつ既存コードの上に重ねていきます。この「漸進的な移行ができる」こと自体が、Rails の懐の深さでもあります。

AI 時代の開発と、設計の関係

ここ最近、AI コーディングアシスタントは開発を劇的に速くしてくれます。ただ、誰もが気づいているとおり、巨大で散らかったコードベースでは AI はうまく機能しません。文脈が広すぎて、何が正解かを掴めないからです。

モジュール化された DDD 設計は、ここでも効いてきます。境界がはっきりしていて、各ドメインの責務が小さくまとまっていれば、AI に渡すコンテキストも明快になります。「この Certificates ドメインの中だけで完結する修正」を頼めば、AI は迷わず的確に動けます。良い設計は、人間にとってだけでなく AI にとっても読みやすい ── これは、これから設計を考えるうえで無視できない観点だと考えています。

得られるもの

観点 効果
スケーラブル 機能を安全に足していける
保守しやすい 境界が明確で、変更の影響範囲が読める
高速 Read Model でクエリが最適化される
AI フレンドリー AI ツールが支援に入りやすい構造

おわりに

複雑な LMS を支えるには、CRUD だけでは足りません。Rails の生産性に、DDD・イベント駆動設計・AI を組み合わせることで、スケールし、変化に適応し、長く保守できるプロダクトをつくれます。

そして大事なのは、この設計思想を早い段階で取り入れることです。技術的負債が雪だるま式に膨らむ前に手を打っておけば、未来の成長に耐えられる土台になります。WisdomBase は、まさにその移行の途中にあります。完成形ではなく、進行形のチャレンジです。

先日参加したRubyConf Thailandの写真

(フン)

 

Building a Modern LMS with Rails, DDD, and AI: Moving Beyond CRUD

Ruby on Rails is great for building apps fast with CRUD. But as systems grow—especially Learning Management Systems (LMS)—this simplicity becomes a limitation. Over time, complexity increases, and traditional Rails apps become harder to maintain. This article explores how Domain-Driven Design (DDD), event-driven architecture, and AI can help build a scalable LMS.

ShareWis Engineering Blog  /  Backend Engineer · Hung

The Problem with CRUD in LMS

A typical LMS includes courses, students, lessons, payments, and certificates. In a CRUD-based Rails app, this often leads to:

  • “God Models” with too many responsibilities
  • Callback-heavy logic (“callback hell”)
  • Scattered business rules
  • Tight coupling between components

For example, completing a course may trigger progress updates, certificates, notifications, and analytics—all tightly linked, making changes risky.

# A common "God Model" — completion logic all piles up here
class Course < ApplicationRecord
  after_update :issue_certificate, if: :just_completed?
  after_update :send_completion_email, if: :just_completed?
  after_update :update_analytics, if: :just_completed?
  # ...every change means praying none of these break
end

Using DDD to Structure the LMS

DDD breaks the system into independent domains:

  • Courses
  • Students
  • Enrollments
  • Progress
  • Payments
  • Certificates

Each domain owns its logic and communicates via events. This modular design improves clarity, scalability, and maintainability. When a new teammate wants to work on payments, it is immediately obvious where to look—and that pays off more than you might expect.

Event-Driven Architecture

Instead of direct calls, domains communicate through events. A domain simply declares what happened; it doesn’t need to know who cleans up afterward.

  • StudentEnrolled
  • CourseCompleted
  • PaymentCompleted

Re-framing “course completion” in this style:

  1. Progress emits CourseCompleted
  2. Certificates issues a certificate
  3. Notifications sends an email
# The publisher just declares "this happened" and moves on
class Progress::CompletionService
  def call(student:, course:)
    progress = record_completion(student, course)
    EventStore.publish(
      CourseCompleted.new(student_id: student.id, course_id: course.id)
    )
    progress
  end
end

# Each subscriber reacts independently
class Certificates::OnCourseCompleted
  def handle(event)
    Certificate.issue!(student_id: event.student_id, course_id: event.course_id)
  end
end

Benefits:

  • Loose coupling — change the certificate logic without touching progress or notifications
  • Easier feature expansion — “also notify Slack on completion” is just one more handler
  • Clear system behavior — the event list reads like a map of what triggers what

Read Models for Performance

Once domains are cleanly separated, cross-cutting screens like dashboards tend to get slow. Complex joins on every request hurt. Read models solve this by storing precomputed data.

Instead of heavy joins, use a student_dashboards table with:

  • Completed courses
  • Progress percentage
  • Last activity

This table subscribes to events and updates accordingly. By separating the write model (the normalized source of truth) from the read model (a projection optimized for display)—the CQRS idea—queries stay fast and simple.

Process Managers for Workflows

Some workflows span multiple domains:

  1. Student enrolls (Enrollments)
  2. Payment completes (Payments)
  3. Course activates (Courses)

Where should this ordered procedure live? Scattering it across domains brings tight coupling back. Instead, a process manager listens to events and triggers the next action, keeping workflow logic clean and centralized.

class EnrollmentProcessManager
  def on(StudentEnrolled)  = request_payment
  def on(PaymentCompleted) = activate_course
  # The "enroll -> pay -> activate" flow lives in this one file
end

Migrating from Legacy Rails

You don’t have to rewrite everything at once. Adopt DDD gradually:

  • Add domains/, read_models/, and process_managers/ directories
  • Introduce an event store
  • Start with read models (high impact, low risk to existing logic)
  • Publish events from services
  • Replace callbacks and tight coupling with event subscriptions, step by step

Don’t do it in one shot. Layer new boundaries on top of existing code little by little, without breaking what works. This ability to migrate incrementally is itself part of what makes Rails so durable.

AI and Modern Development

AI coding assistants speed up development dramatically—but, as everyone has noticed, they struggle with large, messy codebases. There is simply too much context to know what “correct” looks like.

Modular DDD design helps here too. With clear boundaries and small, well-scoped responsibilities, the context you hand the AI becomes crisp. Ask for “a change contained within the Certificates domain,” and the assistant can act precisely. Good design is readable not only for humans, but for AI—an angle that’s hard to ignore when thinking about architecture today.

Benefits

Quality What you get
Scalable Add features safely
Maintainable Clear boundaries, predictable change impact
Performant Optimized queries via read models
AI-friendly Easier for tools to assist

Conclusion

CRUD is not enough for complex LMS systems. By combining Rails with DDD, event-driven design, and AI, you can build applications that scale, adapt, and remain maintainable over time.

Adopting this approach early helps avoid technical debt and prepares your LMS for future growth. WisdomBase is right in the middle of that migration—not a finished product, but a work in progress.

(Hung)

タイトルとURLをコピーしました