← 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

| Issue | Scope | Lines Duplicated | Problem | |-------|-------|------------------|---------| | Identical Typedefs | 20+ controllers | ~100 lines | Each controller defined OnSelectQuestionCompleted, OnBubblePopQuestionCompleted with identical signature | | Stopwatch Boilerplate | ALL 20+ controllers | ~120 lines | Every controller independently managed Stopwatch, initialize(), dispose() | | Answer Initialization | ~15 controllers | ~150 lines | Duplicate answer shuffle + correct answer ID extraction logic | | _updateState Pattern | 20+ controllers | ~80 lines | Identical 4-line method in every controller | | Legacy Bridge Wiring | 20 widgets | ~800 lines | ~25-40 lines of ContentBitAnsweredController setup per widget | | LipSync Initialization | ~15 widgets | ~50 lines | Repeated 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:

| Operation | SelectQuestion | BubblePopQuestion | FillInBlanksQuestion | CalculatorQuestion | |-----------|----------------|-------------------|----------------------|-------------------| | Start timer | initialize() | startPlayground() | startQuestion() | initialize() | | Check answers | checkAnswers() | N/A (auto-checks) | checkAnswers() | checkAnswer() | | Game finished | isChecked | isGameFinished | isCompleted | isChecked |

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:

| Method | Purpose | Used By | |--------|---------|---------| | initialize() | Start question, timer, setup | All 20 controllers | | checkAnswers() | Validate user's answers | 18 controllers | | isFinished (getter) | Check if question complete | All 20 controllers | | isCorrect (getter) | Check if answers correct | All 20 controllers | | elapsedTime (getter) | Get time taken | All 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

| Metric | Before | After | Change | |--------|--------|-------|--------| | 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%) | | SingleQuestionScreen | 1,097 | ~300 | -797 lines (-73%) | | Typedef files | 20+ | 1 | -95% duplication | | Riverpod duplication | ~90% | 0% | Eliminated |

Defect Reduction

| Previous Risk | How 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 dispose | Centralized 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