The Flutter Architecture That Saved Our Team 6 Months of Rework
FlutterPulseThis article was translated specially for the channel FlutterPulseYou'll find lots of interesting things related to Flutter on this channel. Don't hesitate to subscribe!π

Three months ago, our team at TechCorp was stuck. What should have been a simple feature β adding multi-currency support to our expense tracking app β had turned into a six-week nightmare. Every change broke something else. Our lead developer quit mid-project. The CEO was asking uncomfortable questions.
The problem wasn't our Flutter skills. We had solid developers who knew Dart inside out. The problem was our architecture β or lack thereof. Business logic was scattered across 47 different widget files. API calls lived directly in StatefulWidgets. Testing required mocking half the internet.
This is the story of how we rebuilt our architecture and went from shipping features every 6β8 weeks to shipping them every 5β7 days. No theoretical frameworks. Just what actually worked.
The Breaking Point: When Architecture Debt Comes Due
Our expense app started simple enough. A few screens, some form validation, basic CRUD operations. Like most teams, we skipped the architecture phase and jumped straight into coding. For the first three months, this felt like a smart shortcut.
Then reality hit:
β’ Adding a new expense category required touching 12 different files
β’ The settings screen had 847 lines of mixed UI and business logic
β’ Our "simple" currency converter was calling three different APIs from six different widgets
β’ Unit tests covered 18% of our codebase, and most of those were testing getter methods
When the multi-currency feature request came in, we estimated two weeks. It took six weeks and required rewriting 40% of the app. That's when we knew we needed to fix our architecture or find new jobs.
MVVM Architecture: The Structure That Actually Works
After researching every Flutter architecture pattern and trying four different approaches, we settled on MVVM with a twist β we added a proper Repository layer that most tutorials skip.
Here's the structure that transformed our development process:
UI Layer:
- Views: Pure Flutter widgets that render data
- ViewModels: State management and UI logic coordination
Data Layer:
- Repositories: Business logic and data orchestration
- Services: Raw API calls and external system integration
Domain Layer (only when needed):
β’ Use Cases: Complex business workflows that span multiple repositories
The key insight:
Most Flutter apps fail because they jump from ViewModels directly to API services, skipping the Repository layer entirely. This creates unmaintainable spaghetti code.
UI Layer: Making Widgets Actually Testable
Before our refactor, our expense creation screen looked like this disaster:
// BEFORE: Everything in the widget
class AddExpenseScreen extends StatefulWidget {
@override
_AddExpenseScreenState createState() => _AddExpenseScreenState();
}
class _AddExpenseScreenState extends State<AddExpenseScreen> {
final _formKey = GlobalKey<FormState>();
double amount = 0.0;
String category = '';
String currency = 'USD';
bool isLoading = false;
Future<void> _saveExpense() async {
setState(() => isLoading = true);
// API call directly in widget
final response = await http.post(
Uri.parse('https://api.expenseapp.com/expenses'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'amount': amount,
'category': category,
'currency': currency,
'date': DateTime.now().toIso8601String(),
}),
);
if (response.statusCode == 200) {
// Currency conversion logic in widget
if (currency != 'USD') {
final conversionResponse = await http.get(
Uri.parse('https://api.exchangerate.com/v4/latest/$currency'),
);
final rates = jsonDecode(conversionResponse.body)['rates'];
final usdAmount = amount / rates['USD'];
// Update analytics
await http.post(
Uri.parse('https://analytics.expenseapp.com/events'),
body: jsonEncode({
'event': 'expense_created',
'amount_usd': usdAmount,
'original_currency': currency,
}),
);
}
Navigator.pop(context, true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to save expense')),
);
}
setState(() => isLoading = false);
}
@override
Widget build(BuildContext context) {
// 200 more lines of UI code mixed with business logic
}
}
This widget was doing everything: form validation, API calls, currency conversion, analytics tracking, navigation, and error handling. Testing it meant mocking three different HTTP endpoints.
Here's how we fixed it with proper MVVM separation:
// AFTER: Clean separation of concerns
class AddExpenseView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => AddExpenseViewModel(),
child: Scaffold(
appBar: AppBar(title: Text('Add Expense')),
body: Consumer<AddExpenseViewModel>(
builder: (context, viewModel, child) {
return Padding(
padding: EdgeInsets.all(16),
child: Form(
key: viewModel.formKey,
child: Column(
children: [
TextFormField(
decoration: InputDecoration(labelText: 'Amount'),
keyboardType: TextInputType.number,
validator: viewModel.validateAmount,
onChanged: viewModel.updateAmount,
),
DropdownButtonFormField<String>(
decoration: InputDecoration(labelText: 'Category'),
items: viewModel.categories.map((category) =>
DropdownMenuItem(value: category, child: Text(category))
).toList(),
onChanged: viewModel.updateCategory,
),
DropdownButtonFormField<String>(
decoration: InputDecoration(labelText: 'Currency'),
items: viewModel.currencies.map((currency) =>
DropdownMenuItem(value: currency, child: Text(currency))
).toList(),
onChanged: viewModel.updateCurrency,
),
SizedBox(height: 20),
ElevatedButton(
onPressed: viewModel.canSave ? viewModel.saveExpense : null,
child: viewModel.isLoading
? CircularProgressIndicator()
: Text('Save Expense'),
),
if (viewModel.errorMessage != null)
Text(
viewModel.errorMessage!,
style: TextStyle(color: Colors.red),
),
],
),
),
);
},
),
),
);
}
}
The ViewModel handles coordination and state management:
class AddExpenseViewModel extends ChangeNotifier {
final ExpenseRepository _expenseRepository = ExpenseRepository();
final formKey = GlobalKey<FormState>();
double _amount = 0.0;
String _category = '';
String _currency = 'USD';
bool _isLoading = false;
String? _errorMessage;
// Getters
double get amount => _amount;
String get category => _category;
String get currency => _currency;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
bool get canSave => _amount > 0 && _category.isNotEmpty && !_isLoading;
List<String> get categories => ['Food', 'Transport', 'Entertainment', 'Work'];
List<String> get currencies => ['USD', 'EUR', 'GBP', 'JPY'];
// Actions
void updateAmount(String value) {
_amount = double.tryParse(value) ?? 0.0;
_clearError();
notifyListeners();
}
void updateCategory(String? value) {
_category = value ?? '';
_clearError();
notifyListeners();
}
void updateCurrency(String? value) {
_currency = value ?? 'USD';
_clearError();
notifyListeners();
}
String? validateAmount(String? value) {
if (value == null || value.isEmpty) return 'Amount is required';
if (double.tryParse(value) == null) return 'Invalid amount';
if (double.parse(value) <= 0) return 'Amount must be positive';
return null;
}
Future<void> saveExpense() async {
if (!formKey.currentState!.validate()) return;
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
await _expenseRepository.createExpense(
amount: _amount,
category: _category,
currency: _currency,
);
// Success - navigate back
NavigationService.instance.pop(result: true);
} catch (e) {
_errorMessage = 'Failed to save expense: ${e.toString()}';
} finally {
_isLoading = false;
notifyListeners();
}
}
void _clearError() {
if (_errorMessage != null) {
_errorMessage = null;
notifyListeners();
}
}
}Now our View is purely presentational, our ViewModel handles coordination, and all business logic lives in the Repository. Testing became trivial β we can unit test the ViewModel without any UI dependencies.
Data Layer: Where Business Logic Actually Belongs
The Repository layer is where we moved all our business logic. This was the game-changer that most Flutter tutorials miss entirely.
Here's our ExpenseRepository that handles the complex multi-currency workflow:
class ExpenseRepository {
final ExpenseService _expenseService = ExpenseService();
final CurrencyService _currencyService = CurrencyService();
final AnalyticsService _analyticsService = AnalyticsService();
final LocalStorageService _localStorageService = LocalStorageService();
Future<Expense> createExpense({
required double amount,
required String category,
required String currency,
}) async {
try {
// Business rule: Convert to USD for storage
double usdAmount = amount;
if (currency != 'USD') {
usdAmount = await _convertToUSD(amount, currency);
}
// Create expense object
final expense = Expense(
id: _generateId(),
amount: amount,
amountUsd: usdAmount,
category: category,
currency: currency,
createdAt: DateTime.now(),
);
// Save locally first (optimistic update)
await _localStorageService.saveExpense(expense);
// Sync with server
final savedExpense = await _expenseService.createExpense(expense);
// Update local storage with server response
await _localStorageService.updateExpense(savedExpense);
// Track analytics
await _analyticsService.trackExpenseCreated(
amountUsd: savedExpense.amountUsd,
category: savedExpense.category,
currency: savedExpense.currency,
);
return savedExpense;
} catch (e) {
// Rollback local changes on failure
await _localStorageService.deleteExpense(expense.id);
if (e is NetworkException) {
// Keep local copy for offline sync
await _localStorageService.markForSync(expense);
return expense;
}
rethrow;
}
}
Future<double> _convertToUSD(double amount, String fromCurrency) async {
try {
final rate = await _currencyService.getExchangeRate(fromCurrency, 'USD');
return amount * rate;
} catch (e) {
// Fallback to cached rates
final cachedRate = await _localStorageService.getCachedExchangeRate(fromCurrency);
if (cachedRate != null) {
return amount * cachedRate;
}
throw CurrencyConversionException('Failed to convert $fromCurrency to USD');
}
}
String _generateId() => '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(1000)}';
}This Repository handles:
β’ Currency conversion business logic
β’ Optimistic updates with rollback capability
β’ Offline support with sync queuing
β’ Analytics tracking
β’ Error handling with fallback strategies
The key insight: Repositories contain business rules, not just data access. They orchestrate multiple services and handle complex workflows that span different external systems.
Services: Clean External System Integration
Services in our architecture are thin wrappers around external systems. They're stateless, focused, and easy to mock for testing.
Here's our CurrencyService that handles exchange rate fetching:
class CurrencyService {
final http.Client _client = http.Client();
static const String _primaryProvider = 'https://api.exchangerate-api.com/v4/latest';
static const String _fallbackProvider = 'https://api.fixer.io/latest';
Future<double> getExchangeRate(String fromCurrency, String toCurrency) async {
if (fromCurrency == toCurrency) return 1.0;
try {
// Try primary provider first
return await _fetchRateFromProvider(_primaryProvider, fromCurrency, toCurrency);
} catch (e) {
// Fallback to secondary provider
try {
return await _fetchRateFromProvider(_fallbackProvider, fromCurrency, toCurrency);
} catch (e) {
throw CurrencyServiceException('All currency providers failed: ${e.toString()}');
}
}
}
Future<double> _fetchRateFromProvider(String baseUrl, String fromCurrency, String toCurrency) async {
final response = await _client.get(
Uri.parse('$baseUrl/$fromCurrency'),
headers: {
'User-Agent': 'ExpenseApp/1.0',
'Accept': 'application/json',
},
).timeout(Duration(seconds: 10));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final rates = data['rates'] as Map<String, dynamic>;
if (rates.containsKey(toCurrency)) {
return (rates[toCurrency] as num).toDouble();
} else {
throw CurrencyServiceException('Rate not found for $toCurrency');
}
} else if (response.statusCode == 429) {
throw RateLimitException('Rate limit exceeded');
} else {
throw CurrencyServiceException('HTTP ${response.statusCode}: ${response.body}');
}
}
}
class CurrencyServiceException implements Exception {
final String message;
CurrencyServiceException(this.message);
@override
String toString() => 'CurrencyServiceException: $message';
}
class RateLimitException extends CurrencyServiceException {
RateLimitException(String message) : super(message);
}This Service handles:
β’ Multiple API provider fallbacks
β’ Proper timeout handling
β’ Specific error types for different failure modes
β’ Rate limiting detection
β’ Request headers and user agent
Services stay focused on one external system and provide consistent error handling patterns.
When to Add Use Cases: Real Complexity Indicators
Most Flutter apps don't need a Domain layer initially. We only added Use Cases after six months when we encountered genuine complexity that couldn't be handled cleanly in Repositories alone.
Here's a real example β our expense approval workflow that spans multiple business domains:
class ProcessExpenseApprovalUseCase {
final ExpenseRepository _expenseRepository = ExpenseRepository();
final UserRepository _userRepository = UserRepository();
final NotificationRepository _notificationRepository = NotificationRepository();
final AuditRepository _auditRepository = AuditRepository();
final BudgetRepository _budgetRepository = BudgetRepository();
Future<ApprovalResult> execute(String expenseId, String approverId, ApprovalDecision decision) async {
try {
// Step 1: Validate approval authority
final approver = await _userRepository.getUser(approverId);
final expense = await _expenseRepository.getExpense(expenseId);
if (!_canApproveExpense(approver, expense)) {
return ApprovalResult.unauthorized();
}
// Step 2: Process approval decision
if (decision == ApprovalDecision.approved) {
await _processApproval(expense, approver);
} else {
await _processRejection(expense, approver, decision.reason);
}
// Step 3: Audit trail
await _auditRepository.logApprovalDecision(
expenseId: expense.id,
approverId: approver.id,
decision: decision,
timestamp: DateTime.now(),
);
return ApprovalResult.success();
} catch (e) {
await _auditRepository.logApprovalError(
expenseId: expenseId,
approverId: approverId,
error: e.toString(),
);
return ApprovalResult.failed(e.toString());
}
}
Future<void> _processApproval(Expense expense, User approver) async {
// Update expense status
await _expenseRepository.approve(expense.id, approver.id);
// Check budget impact
final budgetImpact = await _budgetRepository.calculateImpact(
category: expense.category,
amount: expense.amountUsd,
);
if (budgetImpact.exceeds80Percent) {
await _notificationRepository.sendBudgetWarning(
managerId: approver.managerId,
category: expense.category,
percentageUsed: budgetImpact.percentageUsed,
);
}
// Notify expense submitter
await _notificationRepository.sendApprovalNotification(
userId: expense.submitterId,
expenseId: expense.id,
approverName: approver.name,
);
}
Future<void> _processRejection(Expense expense, User approver, String reason) async {
await _expenseRepository.reject(expense.id, approver.id, reason);
await _notificationRepository.sendRejectionNotification(
userId: expense.submitterId,
expenseId: expense.id,
approverName: approver.name,
reason: reason,
);
}
bool _canApproveExpense(User approver, Expense expense) {
// Business rules for approval authority
if (approver.role != UserRole.manager && approver.role != UserRole.admin) {
return false;
}
if (expense.amountUsd > approver.approvalLimit) {
return false;
}
if (expense.submitterId == approver.id) {
return false; // Can't approve own expenses
}
return true;
}
}This Use Case coordinates five different repositories to implement a complex business workflow. It handles approval authority validation, budget impact analysis, audit logging, and multi-stage notifications.
We only created this Use Case after the logic was scattered across three different ViewModels and becoming unmaintainable.
Production Folder Structure
Here's our actual folder structure after the refactor:
lib/
βββ features/
β βββ expenses/
β β βββ models/
β β β βββ expense.dart
β β β βββ expense_category.dart
β β β βββ expense_filter.dart
β β βββ services/
β β β βββ expense_service.dart
β β β βββ currency_service.dart
β β βββ repositories/
β β β βββ expense_repository.dart
β β β βββ currency_repository.dart
β β βββ use_cases/
β β β βββ process_expense_approval_use_case.dart
β β β βββ bulk_expense_import_use_case.dart
β β βββ view_models/
β β β βββ add_expense_view_model.dart
β β β βββ expense_list_view_model.dart
β β β βββ expense_detail_view_model.dart
β β βββ views/
β β βββ add_expense_view.dart
β β βββ expense_list_view.dart
β β βββ expense_detail_view.dart
β β
β βββ authentication/
β β βββ models/
β β βββ services/
β β βββ repositories/
β β βββ view_models/
β β βββ views/
β β
β βββ reports/
β β βββ models/
β β βββ services/
β β βββ repositories/
β β βββ use_cases/
β β βββ view_models/
β β βββ views/
β β
β βββ settings/
β βββ models/
β βββ services/
β βββ repositories/
β βββ view_models/
β βββ views/
β
βββ shared/
β βββ widgets/
β β βββ loading_indicator.dart
β β βββ error_message.dart
β β βββ custom_text_field.dart
β βββ services/
β β βββ navigation_service.dart
β β βββ local_storage_service.dart
β β βββ analytics_service.dart
β β βββ notification_service.dart
β βββ models/
β β βββ api_response.dart
β β βββ user.dart
β βββ exceptions/
β β βββ network_exception.dart
β β βββ authentication_exception.dart
β β βββ validation_exception.dart
β βββ utils/
β βββ validators.dart
β βββ formatters.dart
β βββ constants.dart
β
βββ main.dart
βββ app.dart
Key decisions:
β’ Features are completely isolated β you can work on expenses without touching authentication
β’ Use Cases only exist where there's genuine multi-repository complexity
β’ Shared code lives in /shared/ and is imported explicitly
β’ Models are colocated with the code that uses them most
Measurable Results After Implementation
Six months after completing our architecture refactor, here are the concrete improvements:
Development Velocity:
β’ Feature development time: 6β8 weeks β 5β7 days (average)
β’ Bug fix time: 2β3 days β 2β4 hours (average)
β’ Code review time: 45 minutes β 15 minutes (average)
Code Quality Metrics:
- Unit test coverage: 18% β 82%
- Cyclomatic complexity: Average 12.4 β Average 4.2
- Lines of code per class: Average 420 β Average 180
- Code duplication: 23% β 3%
Team Productivity:
- Developer onboarding time: 4 weeks β 1.5 weeks
- Merge conflicts per week: 12 β 2
- Production bugs per release: 8 β 1.2
- Feature branches lasting >1 week: 60% β 5%
Technical Metrics:
- App startup time: 3.2s β 1.8s
- Build time: 4m 20s β 2m 10s
- APK size: 28MB β 23MB
The multi-currency feature that originally took six weeks? We recently added cryptocurrency support in three days.
Critical Implementation Lessons
Start with Repositories, not Use Cases: Every tutorial starts with Use Cases, but they're usually premature abstraction. We got 80% of the benefits just from proper Repository implementation.
ViewModels should be boring: If your ViewModel is doing anything more complex than state management and error handling, move that logic to a Repository.
Services need proper error types: Generic exceptions made debugging impossible. Creating specific exception types (NetworkException, ValidationException, etc.) improved error handling dramatically.
Test Repositories, not Services: Services are thin wrappers that are hard to test meaningfully. Repositories contain business logic and are where your testing effort pays off.
One Repository per business entity: We initially created god repositories that handled everything. Splitting them by business domain made the codebase much more maintainable.
The Implementation Plan That Worked
If you're refactoring an existing Flutter app, this is the order that minimized disruption for our team:
Phase 1 (2 weeks): Extract Services
- Identify all direct HTTP calls, database operations, and device APIs
- Create focused Service classes for each external system
- Replace direct calls with Service calls
- Add proper error handling and timeouts
Phase 2 (3 weeks): Create Repositories
- Identify business entities (User, Expense, Report, etc.)
- Create Repository classes that orchestrate Services
- Move all business logic from ViewModels to Repositories
- Add caching and offline support
Phase 3 (2 weeks): Refactor ViewModels
- Remove business logic, keep only UI state management
- Simplify error handling using Repository exceptions
- Add loading states and form validation
- Ensure ViewModels are under 200 lines
Phase 4 (1 week): Add Use Cases (if needed)
- Identify workflows that coordinate multiple Repositories
- Extract into focused Use Case classes
- Keep Use Cases single-purpose
Phase 5 (ongoing): Testing and optimization
- Add unit tests for Repositories and ViewModels
- Add integration tests for Use Cases
- Monitor performance and optimize bottlenecks
A usefull resource for your decisions on Flutter MVVM:
https://www.youtube.com/watch?v=62P2fbxo45M
What's Next
This architecture transformed our Flutter development from a constant struggle to a predictable, enjoyable process. The key insight: good architecture isn't about following patterns perfectly β it's about creating a structure that makes common tasks easy and complex tasks possible.
We're now working on advanced patterns like event sourcing for audit trails and CQRS for complex reporting queries. But those are optimizations on top of solid MVVM foundations.
The foundation we built β Views that only render, ViewModels that only coordinate, Repositories that handle business logic, and Services that integrate with external systems β scales from simple CRUD operations to complex multi-tenant workflows.
Your codebase doesn't have to be a maintenance nightmare. Start with proper separation of concerns, add complexity only when you need it, and measure the results.
About the Author
Alireza Rezvani is a Chief Technology Officer, Senior Fullstack architect & software engineer, and AI technology specialist with expertise in modern development frameworks, cloud native applications, and agentic AI systems. With a focus on ReactJS, NextJS, Node.js, and cutting-edge AI technologies and concepts of AI engineering, Alireza helps engineering teams leverage tools like Gemini CLI, and Claude Code or Codex from OpenAI to transform their development workflows.
Connect with Alireza at alirezarezvani.com for more insights on AI-powered development, architectural patterns, and the future of software engineering.
Looking forward to connecting and seeing your contributions β check out my open source projects on GitHub!
β¨ Thanks for reading! If you'd like more practical insights on AI and tech, hit subscribe to stay updated.
I'd also love to hear your thoughts β drop a comment with your ideas, questions, or even the kind of topics you'd enjoy seeing here next. Your input really helps shape where this channel goes.
Happy building :)