Keeping the domain pure: no flutter/material imports
The purity of the domain layer comes down to one rule: nothing under lib/domain imports Flutter, HTTP or database packages. That rule buys three measurable things.
1. Test speed and simplicity. Domain tests raise no widgets — no pumpWidget, no frame pumping. A calculation's test runs in milliseconds, which makes hundreds of such tests realistic.
2. Portability. A pure domain runs elsewhere: server-side Dart, a CLI tool, or a second app (an admin panel, say). If the domain knows about Colors.red, none of that is possible.
3. Less coupling to Flutter's version. When Flutter's API changes (widgets, theming, Router), the domain layer needs no edits — the business rules stay put.
The rule also ends an argument: "is this import allowed?" gets answered by a check rather than an opinion.
| Import | In the domain | Reason |
|---|---|---|
| `dart:core`, `dart:async`, `dart:math` | ✅ Allowed | The language itself |
| `package:meta`, `package:collection` | ✅ Allowed | Pure Dart utilities with no Flutter dependency |
| `package:freezed_annotation` | ✅ Allowed | A pure Dart package (`collection`, `json_annotation`, `meta`) |
| `package:flutter/material.dart` | ❌ Forbidden | `Color`, `TimeOfDay`, `BuildContext` — presentation decisions |
| `package:http`, `package:dio` | ❌ Forbidden | A networking detail; the domain must not know "how" |
| `package:sqflite`, `package:shared_preferences` | ❌ Forbidden | A storage detail, and a Flutter plugin |
| `package:intl` (formatting) | ⚠️ Avoid | Date/number formatting is display logic — it belongs to presentation |
| `package:flutter_riverpod` | ❌ Forbidden | Providers live in the presentation/DI layer; the domain does not know them |
// ══ SIZMA 1: rəng domain modelində ══
// ❌ lib/domain/models/order.dart
Color get statusColor => switch (status) {
OrderStatus.pending => Colors.orange,
OrderStatus.paid => Colors.green,
OrderStatus.cancelled => Colors.red,
OrderStatus.unknown => Colors.grey,
};
// ✅ Domain yalnız statusu verir (artıq `enum` var, əlavə heç nə lazım deyil).
// ✅ Rəng presentation-da, temadan istifadə edərək:
// lib/ui/orders/widgets/order_status_chip.dart
Color _colorFor(OrderStatus status, ColorScheme scheme) => switch (status) {
OrderStatus.pending => scheme.tertiary,
OrderStatus.paid => scheme.primary,
OrderStatus.cancelled => scheme.error,
OrderStatus.unknown => scheme.outline,
};
// Əlavə qazanc: rəng artıq temaya bağlıdır, dark mode özü işləyir.
// ══ SIZMA 2: formatlaşdırma domain modelində ══
// ❌ lib/domain/models/order.dart
import 'package:intl/intl.dart';
String get formattedTotal => NumberFormat.currency(symbol: '₼').format(total);
// ✅ Domain: rəqəm (məna). Presentation: mətn (görünüş).
// lib/domain/models/order.dart
final double total;
// lib/ui/core/formatters/money_format.dart
String formatMoney(double value, Locale locale) =>
NumberFormat.currency(locale: locale.toString(), symbol: '₼')
.format(value);
// Əlavə qazanc: lokalizasiya dəyişəndə domain toxunulmur.
// ══ SIZMA 3: `BuildContext` use-case-də ══
// ❌ lib/domain/usecases/place_order.dart
Future<void> call(BuildContext context, Cart cart) async {
final order = await _repository.place(cart);
Navigator.of(context).pushNamed('/success'); // domain naviqasiya edir
}
// ✅ Use-case nəticə qaytarır, naviqasiya presentation-da qalır.
// lib/domain/usecases/place_order.dart
Future<Order> call(Cart cart) => _repository.place(cart);
// lib/ui/checkout/checkout_notifier.dart → view naviqasiyayı özü edir:
// final order = await ref.read(placeOrderProvider)(cart);
// if (context.mounted) context.go('/orders/${order.id}');Three typical leaks and their fixes. In each case the domain returns meaning and the presentation layer turns it into appearance.
How to enforce the rule. There are three levels, from cheap to strong:
1. Convention — one line in ARCHITECTURE.md. Cheapest and weakest: it gets forgotten.
2. A CI check — a grep step (there is an example in the previous topic). Cheap, and it catches violations in the build.
3. A separate package — keeping the domain as an independent Dart package under packages/domain/. With no Flutter dependency in its pubspec.yaml, import 'package:flutter/material.dart' simply does not resolve — the analyzer errors immediately. This is the strongest option, because the language itself enforces the rule.
The third option has a price too: splitting into packages brings pubspec management, versioning and (once there are several packages) a tool like melos. In a small project the second option is entirely sufficient.
Grey areas. Some cases are not obvious and need a team decision: parsing dates with intl (that is conversion rather than formatting — better placed in the mapper), generating a uuid (fine in the domain, since an id is a business concept), logging (an abstraction in the domain, the implementation outside).
Practice. Two steps:
1. Run grep -rEn "package:(flutter|http|dio|sqflite|intl)" lib/domain in your project. For each line found, decide: is it a leak or is it justified? Fix the leaks — usually the fix is moving a getter into the presentation layer.
2. Add that check to tool/check_layers.sh and wire it into CI.
Done means: the script exits with code zero, and the tests under lib/domain run without flutter_test (on package:test).
📚 Sources and documentation
- Dart: creating packagesofficialdart.dev
For extracting the domain into its own package, so the analyzer enforces the rule.
- Flutter: testing introductionofficialdocs.flutter.dev
The difference between unit, widget and integration tests — a pure domain is what makes the unit layer possible.
- Architecture conceptsofficialdocs.flutter.dev
The official explanation of why layers should not depend on each other.