05 - Coding Patterns
Eight deliberate patterns used throughout the codebase. Once you recognize these, the whole app becomes easy to navigate.
Pattern 1 - Layered Architecture
Mixing UI, business logic, persistence, and config in the same file makes code hard to reason about and test.
Solution: Strict 4-layer separation. Dependencies flow one direction: Presentation → Service → Domain → Config. See 04 - Architecture.
Where: Entire lib/ folder structure. See 03 - Project Tour.
Pattern 2 - Repository (Single ApiService Aggregator)
Where do API calls live? One repository per feature creates lots of files to search through. Inline http.post everywhere loses centralized auth/error handling.
Solution: One ApiService class with ~60 methods, one per backend endpoint. Centralized auth, error handling, stage tracking.
flutter_app/lib/services/api_service.dartDart
class ApiService {
final StorageService _storage;
ApiService({required StorageService storage}) : _storage = storage;
// Stage 1: Registration
Future<ApiResponse<Lead>> registrationSignup({...}) async {
return _post<Lead>(
ApiConfig.registrationSignup,
{'mobileNumber': mobile, ...},
fromJson: Lead.fromJson,
);
}
// Stage 2: Mobile OTP
Future<ApiResponse<OtpResult>> verifyMobileOtp(String otp) async { ... }
// ... ~60 methods total, grouped by stage comments
}
Where: flutter_app/lib/services/api_service.dart. See 07 - ApiService for deep dive.
Pattern 3 - Singleton Storage
Multiple screens and ApiService need the same leadId, token, sessionId. Passing them through constructors is tedious. Creating multiple SharedPreferences instances is wasteful.
Solution: StorageService is a singleton. getInstance() returns the same instance app-wide. Initialized once at startup.
flutter_app/lib/services/storage_service.dartDart
class StorageService {
static StorageService? _instance;
final SharedPreferences _prefs;
StorageService._(this._prefs);
static Future<StorageService> getInstance() async {
if (_instance != null) return _instance!;
final prefs = await SharedPreferences.getInstance();
_instance = StorageService._(prefs);
return _instance!;
}
String? get leadId => _prefs.getString('leadId');
Future<void> setLeadId(String id) => _prefs.setString('leadId', id);
String? get token => _prefs.getString('token');
Future<void> setToken(String t) => _prefs.setString('token', t);
// ...
}
Where: flutter_app/lib/services/storage_service.dart. Bootstrapped in main.dart.
Pattern 4 - State Machine UI (enum)
Complex screens have multiple visual modes. E.g. Stage 6 Bank has "select UPI app", "manual entry", "verified". Using multiple boolean flags (isUpiMode, isManual, isVerified) gets tangled and can represent impossible states.
Solution: A Dart enum for the view state. The build method switches on the enum and renders the right sub-view.
flutter_app/lib/screens/stage6_bank.dartDart
enum _BankViewState { upi, manual, verified }
class _Stage6BankScreenState extends State<Stage6BankScreen> {
_BankViewState _viewState = _BankViewState.upi;
Widget _buildBody() {
switch (_viewState) {
case _BankViewState.upi: return _buildUpiSelectionView();
case _BankViewState.manual: return _buildManualEntryView();
case _BankViewState.verified: return _buildVerifiedView();
}
}
}
Where: stage6_bank.dart, stage3_email.dart, stage5_digilocker.dart, stage13_esign.dart. Anywhere a screen has more than 2 visual modes.
Pattern 5 - Wrapper Widget Composition
Loading overlays, session timeouts, back button handling are needed on many screens. Copy-pasting this code is error-prone. Mixins and base classes are hard to compose.
Solution: Each cross-cutting concern is its own widget with a child property. Screens compose them by nesting.
Example composition in any stage screenDart
return BackButtonHandler(
child: SessionTimer(
child: Scaffold(
appBar: AppBar(title: const Text('Bank Verification')),
body: LoadingOverlay(
isLoading: _isLoading,
child: Column(
children: [
const JourneyProgressBar(currentStage: 6),
_buildBody(),
],
),
),
),
),
);
Each wrapper is optional. No mixin, no base class, no inheritance. See 08 - Widgets Reference.
Pattern 6 - Result Type (ApiResponse<T>)
Exceptions for control flow are noisy. Checking try/catch + response.statusCode + parsing errors everywhere is repetitive.
Solution: Every API method returns ApiResponse<T> with success, data, errorCode, displayMessage. Screens check result.success and act.
flutter_app/lib/models/api_response.dartDart
class ApiResponse<T> {
final bool success;
final String? message;
final String? errorCode;
final T? data;
final String displayMessage;
}
Typical screen usageDart
final result = await api.registrationSignup(...);
if (result.success) {
Navigator.pushReplacementNamed(context, AppRoutes.stage2Otp);
} else {
ErrorDialog.show(context, message: result.displayMessage);
}
Pattern 7 - Build-Time Environment Config
Having dev/uat/prod URLs in a runtime config file risks leaking dev URLs into a prod build. Runtime env switching is hackable.
Solution: String.fromEnvironment('ENV') reads a build-time flag. The resolved URL is a compile-time constant. Dead code paths (e.g. the dev URL in a prod build) are tree-shaken out by the Dart compiler.
flutter_app/lib/config/api_config.dartDart
class ApiConfig {
static const _env = String.fromEnvironment('ENV', defaultValue: 'dev');
static String get baseUrl {
switch (_env) {
case 'prod': return 'https://ekyc.motilaloswal.com';
case 'uat': return 'https://ekycuat.motilaloswaluat.com';
default: return 'http://localhost:5000';
}
}
static bool get isDev => _env == 'dev';
static bool get isProd => _env == 'prod';
}
Build commands:
BashBuild per environment
flutter run -d chrome # dev (default)
flutter run -d chrome --dart-define=ENV=uat
flutter run -d chrome --dart-define=ENV=prod
flutter build apk --release --dart-define=ENV=prod
flutter build ipa --release --dart-define=ENV=prod
See 09 - Config & Environments.
Pattern 8 - Backend-Driven Mock Detection
Having a "mock mode" flag on the client is insecure (tamperable) and leads to drift between client and server expectations.
Solution: The client has no mock flag. The backend's GlobalMockEnabled setting controls everything. In mock mode, the backend returns fake redirect URLs (example.com). The client detects these URLs and bypasses external launches - but only in dev builds, guarded by ApiConfig.isDev.
flutter_app/lib/screens/stage5_digilocker.dartDart
if (ApiConfig.isDev &&
(_redirectUrl!.contains('mock-digilocker') ||
_redirectUrl!.contains('example.com'))) {
await _handleMockDigilocker();
return;
}
_initWebView(_redirectUrl!);
In a prod build, ApiConfig.isDev is a compile-time false. The mock branch is dead code and gets removed by tree-shaking. No way to force-mock in prod.
See 02 - Mock Mode for the full explanation.