Migrating an existing messy screen into layers
Most real projects do not start from scratch. Rewriting an existing, layer-free codebase in one go is practically impossible: work stops, an unreviewable giant PR appears, and the risk becomes unmanageable.
The approach that works is incremental, resting on two rules:
1. New code is written with the right rules. This is the most important step: it stops the problem from growing. New features arrive with service plus repository plus notifier, while old screens stay as they are. 2. Old code is migrated when you touch it. If you are fixing a bug or adding a function to a screen, split it into layers first, then make the change. That is the practical form of the boy-scout rule.
Screens nobody ever touches may simply not be migrated — and that is a legitimate decision. Refactoring a screen that has worked unchanged for five years buys nothing and adds risk.
The most important technical rule: structural change and behavioural change never share a commit. That is what makes review possible and lets you find which commit caused a problem.
| Step | What you do | Done when |
|---|---|---|
| 0. Safety net | Write one or two widget tests for the current behaviour (what renders, the main action works) | The tests are green and you can trust them to catch a regression |
| 1. Extract the service | Move the HTTP calls from the widget into a `…ApiClient`; take `http.Client` in the constructor | No `http`, `Uri` or `jsonDecode` in the widget |
| 2. Extract the model | A typed model instead of `Map<String, dynamic>`; JSON keys stay in one file | `raw['key']` appears only in the DTO/mapper file |
| 3. Extract the repository | A contract plus implementation over the service; caching and error translation move here | The widget knows only the contract |
| 4. Extract the notifier | `setState` and `bool _loading` fields move into a notifier and `AsyncValue` | The widget is a `ConsumerWidget` with no `setState` |
| 5. Move the business rules | Calculations (`* 1.18`, `/ 100`) into getters on the domain model | The rule has a unit test and `build` performs no calculation |
| 6. Error flow | `catch (e)` → typed `Failure`; `e.toString()` leaves the screen | All four states (loading, empty, data, error) are handled |
| 7. Complete the tests | Unit tests for the model, mapper, repository and notifier | The widget test from step 0 is still green |
Why this order. Each step builds on the previous one, and each can be committed on its own:
- The service comes first because it is the clearest boundary (HTTP calls are visible) and it immediately makes testing possible.
- The model is second because a repository contract requires a typed model.
- The notifier comes after the repository: otherwise the notifier starts working with
Maps and has to change again later. - Business rules move last, because that step needs the most care — behaviour can change by accident there.
Why step zero matters. A refactor must not change behaviour — but how do you know? With no tests for the existing code, there is no way to see what broke afterwards. So writing one or two widget tests ("the screen shows data", "the button works") is a precondition for the refactor. Such tests are called characterization tests: they record the code's current behaviour, not its ideal behaviour.
When to stop. Finishing the migration completely is not mandatory. The practical target: new code is correct, old code you touch is migrated. Screens nobody ever opens may stay in the old style — refactoring them is pure risk for zero gain.
// ══════ ƏVVƏL: hər şey widget-də ══════
class _ProductsPageState extends State<ProductsPage> {
List<dynamic> _items = [];
bool _loading = true;
String? _error;
Future<void> _load() async {
try {
final response = await http.get(
Uri.parse('https://api.example.com/v1/products?active=true'),
headers: {'Authorization': 'Bearer $kToken'},
);
if (response.statusCode != 200) {
setState(() { _error = 'Xəta: ${response.statusCode}'; });
return;
}
setState(() {
_items = jsonDecode(response.body)['data'] as List<dynamic>;
_loading = false;
});
} catch (e) {
setState(() { _error = e.toString(); _loading = false; });
}
}
// ... build: raw['price_cents'] / 100 və s.
}
// ══════ ADDIM 1-dən SONRA ══════
// Yeni fayl: lib/data/services/product_api_client.dart
class ProductApiClient {
ProductApiClient({required http.Client client, required this.tokenProvider})
: _client = client;
final http.Client _client;
final Future<String?> Function() tokenProvider;
/// Hələ də `List<dynamic>` qaytarır — model ADDIM 2-dədir.
/// Vacib olan: HTTP və status kodu widget-dən ÇIXDI.
Future<List<dynamic>> getActiveProducts() async {
final token = await tokenProvider();
final response = await _client.get(
Uri.parse('https://api.example.com/v1/products?active=true'),
headers: {if (token != null) 'Authorization': 'Bearer $token'},
);
return switch (response.statusCode) {
200 => jsonDecode(response.body)['data'] as List<dynamic>,
401 => throw const UnauthorizedException(),
>= 500 => throw ServerException(response.statusCode),
_ => throw ApiException(response.statusCode, response.body),
};
}
}
// Widget: dəyişiklik minimaldır — bir çağırış.
Future<void> _load() async {
try {
final items = await widget.apiClient.getActiveProducts();
setState(() { _items = items; _loading = false; });
} on UnauthorizedException {
setState(() { _error = 'Sessiya bitdi'; _loading = false; });
} catch (e) {
setState(() { _error = 'Yüklənmə alınmadı'; _loading = false; });
}
}
// ── Bu addımın DƏRHAL qazancı ──
// 1) Service üçün `MockClient` ilə test yazmaq mümkündür:
// status kodları, yanlış JSON, timeout — hamısı yoxlanıla bilər.
// 2) `e.toString()` ekrandan çıxdı.
// 3) İkinci ekran gəldikdə sorğu kopyalanmır.
//
// ── Bu addımda EDİLMƏYƏNLƏR (qəsdən) ──
// • model ayrılmadı (ADDIM 2)
// • repository yoxdur (ADDIM 3)
// • setState qaldı (ADDIM 4)
// Hər addım ayrı commit — review edilə bilən ölçüdə.Step 1: extracting the service. The smallest, safest step, with an immediate payoff.
The most common mistake: everything at once. Work that starts as "let me write this screen properly" turns into a 40-file PR; it cannot be reviewed, it sits unmerged for two weeks accumulating conflicts, and it ends up either rejected or merged without real review.
Committing each step separately is not a technical requirement but a social one: your teammate has time to read a 200-line diff, not a 4,000-line one.
Practice. Pick the most frequently touched screen in your project (check the git history: git log --format=format: --name-only | sort | uniq -c | sort -rn | head) and carry out steps 0-4. Each step gets its own commit.
Done means: there are five commits, flutter analyze && flutter test is green after each, and the widget test from step zero never broke.
📚 Sources and documentation
- Architecture recommendationsofficialdocs.flutter.dev
The migration's target state: which items are mandatory and which are conditional.
- Case study: the data layerofficialdocs.flutter.dev
The target shape of a service and a repository — a reference for migration steps 1-3.
- Flutter: widget testsofficialdocs.flutter.dev
For step zero: writing a widget test that records the current behaviour.