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:
- Controller lifecycle management
- Legacy ContentBitAnsweredController bridge
- LipSync character initialization
- Audio playback
- Animation coordination
- ContentBitMixin lifecycle
- Callback adaptation
- 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:
- Create controller file (~219 lines, copy-paste existing)
- Create widget file (~307 lines, copy-paste existing)
- Create view file (~200 lines)
- Add branch to SingleQuestionScreen match() (~15 lines)
- Total: ~741 lines, 80% boilerplate
Adding new question type after:
- Create controller file (~120 lines, extend BaseQuestionController)
- Create view file (~200 lines)
- Add registry entry (1 line)
- Total: ~321 lines, 10% boilerplate
Time savings: ~60% reduction in new question type implementation time.
Lessons Learned
What Worked Well
- Incremental refactoring: Completed in 6 phases over 2 weeks, each phase independently valuable
- Mixin composition: More flexible than deep inheritance, controllers choose what they need
- Generic base class: Type-safe state management without runtime casts
- Scaffold pattern: Eliminated widget boilerplate while preserving flexibility
What Could Be Improved
- Earlier standardization: Inconsistent naming conventions accumulated technical debt
- Riverpod vs MVC decision: Having both patterns caused duplication, should have chosen one earlier
- 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
- Extract QuestionView Pattern: Further separate view logic from controller
- Async Question Support: Handle questions requiring API calls (e.g., speech-to-text validation)
- Question Composition: Build complex questions from simpler question primitives
- 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