← Back

Eliminating 2,062 Lines of Boilerplate: Questions MVC Refactoring

·architecture

Eliminating 2,062 Lines of Boilerplate: Questions MVC Refactoring

Context

We removed 2,062 lines of duplicated code across 37 question types by introducing BaseQuestionController, shared mixins, and QuestionScaffold pattern. This refactoring reduced MVC boilerplate from 45% to 20% of the codebase, standardized lifecycle management, and unified callback signatures.

The Problem

The Alphazed Amal Flutter application supports 37+ interactive question types (SelectQuestion, BubblePop, FillInBlanks, Calculator, etc.). Each type implemented the MVC pattern independently, resulting in massive code duplication across six critical areas:

Critical DRY Violations

IssueScopeLines DuplicatedProblem
Identical Typedefs20+ controllers~100 linesEach controller defined OnSelectQuestionCompleted, OnBubblePopQuestionCompleted with identical signature
Stopwatch BoilerplateALL 20+ controllers~120 linesEvery controller independently managed Stopwatch, initialize(), dispose()
Answer Initialization~15 controllers~150 linesDuplicate answer shuffle + correct answer ID extraction logic
_updateState Pattern20+ controllers~80 linesIdentical 4-line method in every controller
Legacy Bridge Wiring20 widgets~800 lines~25-40 lines of ContentBitAnsweredController setup per widget
LipSync Initialization~15 widgets~50 linesRepeated LipSyncCustomizedAvatarCharacter setup

Total Duplication: ~1,300 lines of identical code repeated across 20-37 files.

SRP Violations

Widget Files Do Too Much: Each *_widget.dart file handled 8 responsibilities:

  1. Controller lifecycle management
  2. Legacy ContentBitAnsweredController bridge
  3. LipSync character initialization
  4. Audio playback
  5. Animation coordination
  6. ContentBitMixin lifecycle
  7. Callback adaptation
  8. Widget tree rendering

SingleQuestionScreen God Object: 1,097 lines with 37 _build*() methods, 90+ imports, and a 45-branch union match statement for question type dispatch.

Inconsistent Contracts

Different question types used different method names for identical operations:

OperationSelectQuestionBubblePopQuestionFillInBlanksQuestionCalculatorQuestion
Start timerinitialize()startPlayground()startQuestion()initialize()
Check answerscheckAnswers()N/A (auto-checks)checkAnswers()checkAnswer()
Game finishedisCheckedisGameFinishedisCompletedisChecked

This inconsistency made it impossible to write generic code that worked across question types.

ASCII Diagram: Before State

┌────────────────────────────────────────────────────────────────┐
│              SingleQuestionScreen (1,097 lines)                │
│                                                                │
│  union.match<Widget>(                                          │
│    selectText: (model) => SelectQuestionWidget(...),           │
│    bubblePop: (model) => BubblePopQuestionWidget(...),         │
│    fillInBlanks: (model) => FillInBlanksQuestionWidget(...),   │
│    ... 34 more branches                                        │
│  )                                                             │
└────────────────────────────────────────────────────────────────┘
                               │
        ┌──────────────────────┼──────────────────────┐
        │                      │                      │
┌───────▼──────────┐  ┌────────▼─────────┐  ┌───────▼──────────┐
│ SelectQuestion   │  │ BubblePopQuestion│  │ FillInBlanks     │
│ Widget           │  │ Widget           │  │ Widget           │
│ (307 lines)      │  │ (298 lines)      │  │ (312 lines)      │
├──────────────────┤  ├──────────────────┤  ├──────────────────┤
│ DUPLICATED CODE: │  │ DUPLICATED CODE: │  │ DUPLICATED CODE: │
│                  │  │                  │  │                  │
│ • Typedef        │  │ • Typedef        │  │ • Typedef        │
│ • _updateState() │  │ • _updateState() │  │ • _updateState() │
│ • Stopwatch      │  │ • Stopwatch      │  │ • Stopwatch      │
│ • Bridge setup   │  │ • Bridge setup   │  │ • Bridge setup   │
│ • LipSync init   │  │ • LipSync init   │  │ • LipSync init   │
│ • initState      │  │ • initState      │  │ • initState      │
│ • dispose        │  │ • dispose        │  │ • dispose        │
│ • Answer shuffle │  │ • Answer shuffle │  │ • Answer shuffle │
│                  │  │                  │  │                  │
│ UNIQUE CODE:     │  │ UNIQUE CODE:     │  │ UNIQUE CODE:     │
│ • selectAnswer() │  │ • popBubble()    │  │ • fillBlank()    │
│ • checkAnswers() │  │ • startGame()    │  │ • checkAnswers() │
└──────────────────┘  └──────────────────┘  └──────────────────┘

... 34 more question widgets with identical duplication pattern

Result: ~5,700 lines of MVC code, 45% of which was boilerplate duplication.


The Solution

We implemented a 6-phase refactoring strategy that systematically eliminated duplication using base classes, mixins, and composition patterns.

Phase 1: Shared Types & Mixins

Created unified typedef (OnQuestionTypeCompleted):

typedef OnQuestionTypeCompleted = void Function({
  required String questionId,
  required bool isCorrect,
  required Duration timeTaken,
});

Created QuestionTimerMixin (6 lines, replaces ~120 lines across 20 controllers):

mixin QuestionTimerMixin {
  final Stopwatch _stopwatch = Stopwatch();

  void startTimer() => _stopwatch.start();
  void stopTimer() => _stopwatch.stop();
  Duration get elapsedTime => _stopwatch.elapsed;
  void disposeTimer() => _stopwatch.stop();
}

Created AnswerShuffleMixin (12 lines, replaces ~150 lines across 13 controllers):

mixin AnswerShuffleMixin {
  List<Answer> initializeAnswers(List<Answer> answers, {required bool shuffle}) {
    final shuffled = shuffle ? (answers..shuffle()) : answers;
    final correctId = shuffled.firstWhere((a) => a.isCorrect).id;
    return shuffled;
  }
}

Lines saved: ~370 lines across 20 files.


Phase 2: BaseQuestionController

Before (repeated in every controller):

class SelectQuestionController extends ChangeNotifier {
  SelectQuestionState _state;
  final Stopwatch _stopwatch = Stopwatch();

  void initialize() {
    _stopwatch.start();
    // ... setup logic
  }

  void _updateState(SelectQuestionState newState) {
    _state = newState;
    notifyListeners();
  }

  @override
  void dispose() {
    _stopwatch.stop();
    super.dispose();
  }

  // ... 150 lines of type-specific logic
}

After (shared base class):

abstract class BaseQuestionController<S> extends ChangeNotifier {
  S _state;

  S get state => _state;

  void updateState(S newState) {
    _state = newState;
    notifyListeners();
  }

  void initialize(); // Subclass implements

  @override
  void dispose() {
    super.dispose();
  }
}

Controller now extends base:

class SelectQuestionController extends BaseQuestionController<SelectQuestionState>
    with QuestionTimerMixin, AnswerShuffleMixin {

  @override
  void initialize() {
    startTimer();
    final answers = initializeAnswers(model.answers, shuffle: true);
    updateState(SelectQuestionState.initial(answers: answers));
  }

  // Only type-specific logic remains (90 lines vs 219 lines before)
  void selectAnswer(String answerId) { ... }
  void checkAnswers() { ... }
}

Lines saved: ~80 lines per controller × 20 controllers = ~1,600 lines.


Phase 3: QuestionScaffold Widget

Before (repeated in every widget):

class SelectQuestionWidget extends StatefulWidget {
  @override
  _SelectQuestionState createState() => _SelectQuestionState();
}

class _SelectQuestionState extends State<SelectQuestionWidget>
    with ContentBitMixin {
  late SelectQuestionController _controller;
  late ContentBitAnsweredController _bridgeController;
  late LipSyncCustomizedAvatarCharacter _lipSyncCharacter;

  @override
  void initState() {
    super.initState();
    _controller = SelectQuestionController(widget.model);
    _controller.addListener(_onStateChanged);

    // Bridge setup (10 lines)
    _bridgeController = ContentBitAnsweredController();
    _bridgeController.onContinue = _handleContinue;
    _bridgeController.onSkip = _handleSkip;

    // LipSync setup (8 lines)
    _lipSyncCharacter = LipSyncCustomizedAvatarCharacter.fromMatchingUrl(
      audioUrl: widget.model.audioUrl,
      characterKey: 'teacher',
    );

    _controller.initialize();
  }

  void _onStateChanged() {
    if (_controller.isFinished) {
      _bridgeController.complete(
        isCorrect: _controller.isCorrect,
        timeTaken: _controller.elapsedTime,
      );
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    _bridgeController.dispose();
    _lipSyncCharacter.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return SelectQuestionView(controller: _controller);
  }
}

After (shared scaffold):

class QuestionScaffold<C extends BaseQuestionController> extends StatefulWidget {
  final QuestionModel model;
  final C Function(QuestionModel) createController;
  final Widget Function(C) viewBuilder;
  final OnQuestionTypeCompleted onCompleted;

  @override
  _QuestionScaffoldState<C> createState() => _QuestionScaffoldState<C>();
}

class _QuestionScaffoldState<C extends BaseQuestionController>
    extends State<QuestionScaffold<C>> with ContentBitMixin {
  late C _controller;
  late ContentBitAnsweredController _bridgeController;
  late LipSyncCustomizedAvatarCharacter _lipSyncCharacter;

  @override
  void initState() {
    super.initState();
    _controller = widget.createController(widget.model);
    _controller.addListener(_onStateChanged);

    _bridgeController = ContentBitAnsweredController();
    _bridgeController.onContinue = _handleContinue;
    _bridgeController.onSkip = _handleSkip;

    _lipSyncCharacter = LipSyncCustomizedAvatarCharacter.fromMatchingUrl(
      audioUrl: widget.model.audioUrl,
      characterKey: 'teacher',
    );

    _controller.initialize();
  }

  void _onStateChanged() {
    if (_controller.isFinished) {
      widget.onCompleted(
        questionId: widget.model.id,
        isCorrect: _controller.isCorrect,
        timeTaken: _controller.elapsedTime,
      );
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    _bridgeController.dispose();
    _lipSyncCharacter.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => widget.viewBuilder(_controller);
}

Widget now uses scaffold:

class SelectQuestionWidget extends StatelessWidget {
  final SelectQuestionModel model;
  final OnQuestionTypeCompleted onCompleted;

  @override
  Widget build(BuildContext context) {
    return QuestionScaffold<SelectQuestionController>(
      model: model,
      createController: (m) => SelectQuestionController(m),
      viewBuilder: (controller) => SelectQuestionView(controller: controller),
      onCompleted: onCompleted,
    );
  }
}

Lines saved: ~40 lines per widget × 20 widgets = ~800 lines.


Phase 4: QuestionTypeRegistry (Factory Pattern)

Before (SingleQuestionScreen.dart, 1,097 lines):

class SingleQuestionScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return contentBitUnion.match<Widget>(
      selectText: (model) => SelectQuestionWidget(model: model, ...),
      bubblePop: (model) => BubblePopQuestionWidget(model: model, ...),
      fillInBlanks: (model) => FillInBlanksQuestionWidget(model: model, ...),
      calculator: (model) => CalculatorQuestionWidget(model: model, ...),
      // ... 33 more branches (500 lines of match arms)
      unknown: () => UnknownQuestionWidget(),
    );
  }

  // ... 37 helper methods _buildXxx(), each 10-20 lines
}

After (registry-based dispatch):

class QuestionTypeRegistry {
  static final Map<String, QuestionWidgetBuilder> _builders = {
    'select_text': (model, onCompleted) => SelectQuestionWidget(
      model: model as SelectQuestionModel,
      onCompleted: onCompleted,
    ),
    'bubble_pop': (model, onCompleted) => BubblePopQuestionWidget(
      model: model as BubblePopQuestionModel,
      onCompleted: onCompleted,
    ),
    // ... 35 more entries (1 line each)
  };

  static Widget build(String type, QuestionModel model, OnQuestionTypeCompleted onCompleted) {
    final builder = _builders[type];
    if (builder == null) return UnknownQuestionWidget();
    return builder(model, onCompleted);
  }
}

SingleQuestionScreen simplified:

class SingleQuestionScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return QuestionTypeRegistry.build(
      contentBitUnion.unionTypeName,
      contentBitUnion.innerModel,
      _onQuestionCompleted,
    );
  }

  void _onQuestionCompleted({required String questionId, ...}) {
    // Handle completion (20 lines, was 100 lines)
  }
}

Lines saved: ~500 lines (1,097 → ~300 lines, 73% reduction).


Phase 5: Eliminate Riverpod Duplication

Problem: SelectQuestion had BOTH MVC controller AND Riverpod provider with ~90% duplicated logic.

Before:

// MVC controller (219 lines)
class SelectQuestionController extends ChangeNotifier { ... }

// Riverpod provider (197 lines, ~90% duplicate logic)
class SelectQuestionNotifier extends StateNotifier<SelectQuestionState> { ... }

// Widget used Riverpod
class SelectQuestionWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final notifier = ref.watch(selectQuestionProvider);
    ...
  }
}

After:

// Single MVC controller (150 lines, uses BaseQuestionController)
class SelectQuestionController extends BaseQuestionController<SelectQuestionState> { ... }

// Riverpod wraps MVC controller
final selectQuestionProvider = ChangeNotifierProvider.autoDispose.family<
  SelectQuestionController, SelectQuestionModel
>((ref, model) => SelectQuestionController(model));

// Widget uses Riverpod wrapper (API unchanged)
class SelectQuestionWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final controller = ref.watch(selectQuestionProvider(model));
    ...
  }
}

Lines saved: ~475 lines across 3 Riverpod-using question types.


Phase 6: State Class Cleanup

Moved business logic from state classes to controllers:

Before (logic in state):

class FillInBlanksQuestionState {
  final Map<String, String> userAnswers;

  bool get isAnsweredCorrectly {
    // Business logic in state (15 lines)
    return userAnswers.entries.every((entry) =>
      correctAnswers[entry.key] == entry.value);
  }
}

After (logic in controller):

class FillInBlanksQuestionState {
  final Map<String, String> userAnswers;
  // Pure data, no logic
}

class FillInBlanksQuestionController extends BaseQuestionController {
  bool checkAnswers() {
    // Business logic in controller (15 lines)
    final isCorrect = state.userAnswers.entries.every((entry) =>
      model.correctAnswers[entry.key] == entry.value);
    updateState(state.copyWith(isCorrect: isCorrect));
    return isCorrect;
  }
}

Net change: Small adjustment (+5 controller, -32 state), improved SRP.


ASCII Diagram: After State

┌──────────────────────────────────────────────────────────────┐
│          SingleQuestionScreen (300 lines, -73%)              │
│                                                              │
│  QuestionTypeRegistry.build(                                │
│    type: union.unionTypeName,                               │
│    model: union.innerModel,                                 │
│    onCompleted: _handleCompletion                           │
│  )                                                          │
└──────────────────────────────────────────────────────────────┘
                               │
                ┌──────────────┴──────────────┐
                │   QuestionTypeRegistry      │
                │   (Map-based O(1) lookup)   │
                └──────────────┬──────────────┘
                               │
        ┌──────────────────────┼──────────────────────┐
        │                      │                      │
┌───────▼──────────┐  ┌────────▼─────────┐  ┌───────▼──────────┐
│ QuestionScaffold │  │ QuestionScaffold │  │ QuestionScaffold │
│<SelectController>│  │<BubbleController>│  │<FillInController>│
│                  │  │                  │  │                  │
│ [Handles:]       │  │ [Handles:]       │  │ [Handles:]       │
│ • Bridge         │  │ • Bridge         │  │ • Bridge         │
│ • LipSync        │  │ • LipSync        │  │ • LipSync        │
│ • Lifecycle      │  │ • Lifecycle      │  │ • Lifecycle      │
│ • Callbacks      │  │ • Callbacks      │  │ • Callbacks      │
└────────┬─────────┘  └────────┬─────────┘  └────────┬─────────┘
         │                     │                     │
    ┌────▼────┐           ┌────▼────┐           ┌────▼────┐
    │ Select  │           │ Bubble  │           │FillIn   │
    │Controller│           │Controller│           │Controller│
    │(150 ln) │           │(142 ln) │           │(165 ln) │
    ├─────────┤           ├─────────┤           ├─────────┤
    │extends  │           │extends  │           │extends  │
    │Base     │           │Base     │           │Base     │
    │Quest.   │           │Quest.   │           │Quest.   │
    │Control. │           │Control. │           │Control. │
    ├─────────┤           ├─────────┤           ├─────────┤
    │with     │           │with     │           │with     │
    │Timer    │           │Timer    │           │Timer    │
    │Mixin    │           │Mixin    │           │Mixin    │
    │Answer   │           │Answer   │           │Answer   │
    │Mixin    │           │Mixin    │           │Mixin    │
    └─────────┘           └─────────┘           └─────────┘

... 34 more question types following same pattern

Result: ~3,638 lines of MVC code (down from ~5,700), 20% boilerplate (down from 45%).


Implementation Details

Standardized Naming Conventions

All controllers now follow consistent contracts:

MethodPurposeUsed By
initialize()Start question, timer, setupAll 20 controllers
checkAnswers()Validate user's answers18 controllers
isFinished (getter)Check if question completeAll 20 controllers
isCorrect (getter)Check if answers correctAll 20 controllers
elapsedTime (getter)Get time takenAll 20 controllers (via mixin)

Mixin Composition Pattern

Controllers compose behavior using mixins rather than deep inheritance:

// Simple questions: timer only
class TrueFalseController extends BaseQuestionController with QuestionTimerMixin { ... }

// Complex questions: timer + answer shuffle
class SelectQuestionController extends BaseQuestionController
    with QuestionTimerMixin, AnswerShuffleMixin { ... }

// Specialized questions: no mixins needed
class CalculatorController extends BaseQuestionController {
  // Doesn't need timer (untimed) or answer shuffle (no options)
}

Type Safety with Generics

BaseQuestionController uses generics to ensure type-safe state management:

abstract class BaseQuestionController<S> extends ChangeNotifier {
  S _state;

  S get state => _state; // Type-safe state access

  void updateState(S newState) { // Type-safe state updates
    _state = newState;
    notifyListeners();
  }
}

// Usage
class SelectQuestionController extends BaseQuestionController<SelectQuestionState> {
  // Compiler enforces: updateState() must receive SelectQuestionState
  void selectAnswer(String id) {
    updateState(state.copyWith(selectedAnswerId: id)); // Type-checked
  }
}

Impact and Results

Code Metrics

MetricBeforeAfterChange
Total MVC lines~5,700~3,638-2,062 lines (-36%)
Boilerplate %45%20%-55% boilerplate ratio
Lines per controller~219 avg~150 avg-69 lines (-32%)
Lines per widget~307 avg~15 avg-292 lines (-95%)
SingleQuestionScreen1,097~300-797 lines (-73%)
Typedef files20+1-95% duplication
Riverpod duplication~90%0%Eliminated

Defect Reduction

Previous RiskHow Mitigated
Typos in lifecycle methods (initialize() vs startPlayground())Single initialize() contract in base class
Inconsistent state flags (isChecked vs isGameFinished)Single isFinished property pattern
Forgot _stopwatch.stop() in disposeCentralized disposeTimer() in mixin
Missing bridge wiring (addListener forgotten)Centralized in QuestionScaffold
Riverpod drift (logic diverges from MVC)Eliminated Riverpod duplication entirely

Developer Velocity Improvements

Adding new question type before:

  1. Create controller file (~219 lines, copy-paste existing)
  2. Create widget file (~307 lines, copy-paste existing)
  3. Create view file (~200 lines)
  4. Add branch to SingleQuestionScreen match() (~15 lines)
  5. Total: ~741 lines, 80% boilerplate

Adding new question type after:

  1. Create controller file (~120 lines, extend BaseQuestionController)
  2. Create view file (~200 lines)
  3. Add registry entry (1 line)
  4. Total: ~321 lines, 10% boilerplate

Time savings: ~60% reduction in new question type implementation time.


Lessons Learned

What Worked Well

  1. Incremental refactoring: Completed in 6 phases over 2 weeks, each phase independently valuable
  2. Mixin composition: More flexible than deep inheritance, controllers choose what they need
  3. Generic base class: Type-safe state management without runtime casts
  4. Scaffold pattern: Eliminated widget boilerplate while preserving flexibility

What Could Be Improved

  1. Earlier standardization: Inconsistent naming conventions accumulated technical debt
  2. Riverpod vs MVC decision: Having both patterns caused duplication, should have chosen one earlier
  3. Testing during refactoring: Added tests retroactively instead of TDD approach

Trade-offs Made

Verbosity vs DRY: Base class + mixins require more files but eliminate duplication

  • Before: 1 file with 219 lines (includes duplication)
  • After: 1 base class (50 lines) + 2 mixins (18 lines) + controller (120 lines) = 188 lines
  • Net: Slightly more verbose, but shared across 20 controllers (2,400 lines saved)

Flexibility vs Convention: Scaffold pattern reduces flexibility but ensures consistency

  • Can't customize bridge/LipSync/lifecycle per widget
  • But 20/20 question types don't need customization
  • Trade-off: 95% simplification for 5% constraint

Next Steps

Completed ✅

  • All 20 controllers migrated to BaseQuestionController
  • All 20 widgets use QuestionScaffold
  • SingleQuestionScreen uses registry pattern
  • Riverpod providers migrated to ChangeNotifierProvider wrappers

Future Opportunities

  1. Extract QuestionView Pattern: Further separate view logic from controller
  2. Async Question Support: Handle questions requiring API calls (e.g., speech-to-text validation)
  3. Question Composition: Build complex questions from simpler question primitives
  4. Performance Monitoring: Track initialization time, memory usage per question type

Related Work

  • Sections MVC Refactoring: Applied similar patterns to 60+ section types (see blog post #2)
  • Serialization Modernization: Replaced built_union with Dart 3 sealed classes (see blog post #3)
  • Factory Pattern Adoption: Used across questions, sections, and widget builders (see blog post #7)

Date: February 2026 Commits: 9c8bb32, 3cac022, 99574dc, 9e3b755 Files Changed: 168 files, +2,587 lines, -4,649 lines Test Coverage: All 20 question types tested, 100% pass rate