A feature from scratch: service → repository → notifier → view
This topic is practice: you combine everything from the branch into one feature. The order matters, because it surfaces wrong decisions early.
The right order — outside in:
1. Write down what the screen must show (on paper or as TODOs): which data, which states (loading, empty, error), which actions.
2. Write the domain model — shaped by the screen's needs, not by the backend's JSON.
3. Write the repository contract — derived from what the notifier needs.
4. Write the `…Local` implementation — canned data plus latency plus a failure rate.
5. Build the notifier and the screen — render all four states.
6. Write the tests — against the fake.
7. Only then write the DTO, the mapper and the …Remote implementation.
The reason for this order: at step 7 you should not have to touch the screen code. If you do, the contract had leaked the backend's shape.
The reverse order (starting from the backend) produces a familiar result: the DTO's shape flows into the domain model, the domain model into the screen, and the UI ends up a mirror of the JSON.
FEATURE: Loyallıq kartı (bal toplamaq və hədiyyəyə dəyişmək)
lib/
├── domain/
│ ├── models/
│ │ ├── loyalty_card.dart ← points, tier, biznes getter-ləri
│ │ └── loyalty_reward.dart ← id, title, cost
│ ├── repositories/
│ │ └── loyalty_repository.dart ← abstract müqavilə (ADDIM 3)
│ └── failures/
│ └── failure.dart ← + InsufficientPointsFailure
├── data/
│ ├── dto/
│ │ ├── loyalty_card_dto.dart ← ADDIM 7 (sonda!)
│ │ └── loyalty_card_mapper.dart ← ADDIM 7
│ ├── services/
│ │ └── loyalty_api_client.dart ← ADDIM 7
│ └── repositories/
│ ├── loyalty_repository_local.dart ← ADDIM 4 (əvvəl!)
│ └── loyalty_repository_remote.dart ← ADDIM 7
└── ui/
└── loyalty/
├── loyalty_notifier.dart ← ADDIM 5 (+ .g.dart)
├── loyalty_state.dart ← ADDIM 5 (+ .freezed.dart)
├── loyalty_page.dart ← ADDIM 5
└── widgets/
├── points_header.dart
├── reward_tile.dart
└── empty_rewards.dart
test/
├── fixtures/
│ └── loyalty_fixtures.dart ← aLoyaltyCard(), aReward()
├── fakes/
│ └── fake_loyalty_repository.dart ← ADDIM 6
├── domain/models/loyalty_card_test.dart
├── data/dto/loyalty_card_mapper_test.dart
├── data/repositories/loyalty_repository_remote_test.dart
└── ui/loyalty/
├── loyalty_notifier_test.dart
└── loyalty_page_test.dart
CƏMİ: 12 mənbə faylı + 7 test faylı.
Qeyd: `usecases/` qovluğu BOŞDUR — bu feature-də üç şərtdən heç biri
ödənmir (bir repository, sadə məntiq, bir ekran). Use-case yazılmır.The complete file list for one feature — the practice's answer to "what do I create?"
// ══ ADDIM 2: domain modeli (ekranın ehtiyacına görə) ══
// lib/domain/models/loyalty_card.dart
@freezed
abstract class LoyaltyCard with _$LoyaltyCard {
const LoyaltyCard._();
const factory LoyaltyCard({
required int points,
required LoyaltyTier tier,
required int pointsToNextTier,
}) = _LoyaltyCard;
// Biznes qaydaları modeldə: ekran onları təkrarlamır.
bool canAfford(LoyaltyReward reward) => points >= reward.cost;
int missingPointsFor(LoyaltyReward reward) =>
(reward.cost - points).clamp(0, reward.cost);
double get tierProgress => pointsToNextTier == 0
? 1
: points / (points + pointsToNextTier);
}
enum LoyaltyTier { bronze, silver, gold, unknown }
// ══ ADDIM 3: müqavilə (notifier-in ehtiyacından çıxarılır) ══
// lib/domain/repositories/loyalty_repository.dart
abstract interface class LoyaltyRepository {
/// Cari istifadəçinin kartı.
Future<Result<LoyaltyCard>> fetchCard();
/// Mövcud hədiyyələr.
Future<Result<List<LoyaltyReward>>> fetchRewards();
/// Hədiyyəni bala dəyişir və yenilənmiş kartı qaytarır.
/// Bal çatmadıqda `InsufficientPointsFailure`.
Future<Result<LoyaltyCard>> redeem(String rewardId);
}
// Yoxlama: bu müqaviləyə baxıb backend-in REST, GraphQL, yoxsa
// lokal baza olduğunu təxmin etmək MÜMKÜN DEYİL. Müqavilə düzgündür.
// ══ ADDIM 4: Local implementasiya (backend-dən ƏVVƏL) ══
// lib/data/repositories/loyalty_repository_local.dart
class LoyaltyRepositoryLocal implements LoyaltyRepository {
LoyaltyRepositoryLocal({
this.latency = const Duration(milliseconds: 600),
this.failureRate = 0,
});
final Duration latency;
final double failureRate;
LoyaltyCard _card = const LoyaltyCard(
points: 1250,
tier: LoyaltyTier.silver,
pointsToNextTier: 750,
);
static const _rewards = [
LoyaltyReward(id: 'r1', title: 'Pulsuz çatdırılma', cost: 500),
LoyaltyReward(id: 'r2', title: '10% endirim', cost: 1000),
LoyaltyReward(id: 'r3', title: 'Hədiyyə dəsti', cost: 5000),
];
@override
Future<Result<LoyaltyCard>> fetchCard() async {
await Future<void>.delayed(latency);
if (_fails()) return const Result.error(NetworkFailure());
return Result.ok(_card);
}
@override
Future<Result<List<LoyaltyReward>>> fetchRewards() async {
await Future<void>.delayed(latency);
if (_fails()) return const Result.error(NetworkFailure());
return const Result.ok(_rewards);
}
@override
Future<Result<LoyaltyCard>> redeem(String rewardId) async {
await Future<void>.delayed(latency);
final reward = _rewards.firstWhere((r) => r.id == rewardId);
// Biznes qaydası burada da işləyir — ekran onu sınayır.
if (!_card.canAfford(reward)) {
return Result.error(
InsufficientPointsFailure(missing: _card.missingPointsFor(reward)),
);
}
_card = _card.copyWith(points: _card.points - reward.cost);
return Result.ok(_card);
}
bool _fails() => failureRate > 0 && Random().nextDouble() < failureRate;
}Steps 2-3: the domain model and the contract. Note the absence of JSON and HTTP.
// ══ lib/ui/loyalty/loyalty_state.dart ══
@freezed
abstract class LoyaltyState with _$LoyaltyState {
const factory LoyaltyState({
required LoyaltyCard card,
required List<LoyaltyReward> rewards,
@Default(null) String? redeemingRewardId, // hansı hədiyyə gedir
}) = _LoyaltyState;
}
// ══ lib/ui/loyalty/loyalty_notifier.dart ══
@riverpod
class LoyaltyNotifier extends _$LoyaltyNotifier {
@override
Future<LoyaltyState> build() async {
final repo = ref.watch(loyaltyRepositoryProvider);
// İki sorğu paralel — ardıcıl gözləmək lazım deyil.
final (cardResult, rewardsResult) =
await (repo.fetchCard(), repo.fetchRewards()).wait;
// İlk uğursuzluq bütün ekranı xəta halına aparır.
return switch ((cardResult, rewardsResult)) {
(Ok(value: final card), Ok(value: final rewards)) =>
LoyaltyState(card: card, rewards: rewards),
(Error(:final error), _) => throw error,
(_, Error(:final error)) => throw error,
};
// `throw` → AsyncNotifier onu AsyncError-a çevirir.
}
/// Command: hədiyyəni bala dəyişir, nəticəni view-a qaytarır.
Future<RedeemOutcome> redeem(String rewardId) async {
final current = state.value;
if (current == null) return RedeemOutcome.ignored;
// İkiqat basılma müdafiəsi.
if (current.redeemingRewardId != null) return RedeemOutcome.ignored;
state = AsyncValue.data(
current.copyWith(redeemingRewardId: rewardId),
);
final result =
await ref.read(loyaltyRepositoryProvider).redeem(rewardId);
switch (result) {
case Ok(value: final card):
state = AsyncValue.data(
current.copyWith(card: card, redeemingRewardId: null),
);
return RedeemOutcome.success;
case Error(error: InsufficientPointsFailure(:final missing)):
state = AsyncValue.data(current.copyWith(redeemingRewardId: null));
return RedeemOutcome.notEnoughPoints(missing: missing);
case Error():
state = AsyncValue.data(current.copyWith(redeemingRewardId: null));
return RedeemOutcome.failed;
}
}
}
// ══ lib/ui/loyalty/loyalty_page.dart ══
class LoyaltyPage extends ConsumerWidget {
const LoyaltyPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final state = ref.watch(loyaltyNotifierProvider);
return Scaffold(
appBar: AppBar(title: Text(l10n.loyaltyTitle)),
body: switch (state) {
// 1) Boş hal (xəta deyil)
AsyncData(:final value) when value.rewards.isEmpty =>
EmptyRewards(points: value.card.points),
// 2) Məlumat
AsyncData(:final value) => Column(children: [
PointsHeader(card: value.card),
Expanded(
child: ListView.builder(
itemCount: value.rewards.length,
itemBuilder: (context, i) {
final reward = value.rewards[i];
return RewardTile(
reward: reward,
// Biznes qaydası MODELDƏN gəlir, widget-də deyil.
canAfford: value.card.canAfford(reward),
isRedeeming: value.redeemingRewardId == reward.id,
onRedeem: () => _redeem(context, ref, reward),
);
},
),
),
]),
// 3) Xəta
AsyncError(:final error) => ErrorView(
message: error is Failure
? presentFailure(error, l10n).message
: l10n.errorGeneric,
onRetry: () => ref.invalidate(loyaltyNotifierProvider),
),
// 4) Yüklənir
_ => const Center(child: CircularProgressIndicator()),
},
);
}
Future<void> _redeem(
BuildContext context, WidgetRef ref, LoyaltyReward reward) async {
final outcome =
await ref.read(loyaltyNotifierProvider.notifier).redeem(reward.id);
if (!context.mounted) return;
final l10n = AppLocalizations.of(context);
final message = switch (outcome) {
RedeemSuccess() => l10n.redeemSuccess(reward.title),
RedeemNotEnoughPoints(:final missing) =>
l10n.redeemNeedMorePoints(missing),
RedeemFailed() => l10n.errorGeneric,
RedeemIgnored() => null,
};
if (message == null) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
}Step 5: the notifier and the screen. With the `…Local` implementation all four states are genuinely exercised.
Practice (this stage's main assignment). Repeat the seven steps above for one feature in your own project. Pick the feature yourself — keep it small (one screen, two or three operations) but real.
Stay faithful to the order: the `…Local` implementation before the backend. That is the entire point of the sequence.
Done means:
1. flutter run -t lib/main_development.dart runs the screen and you have seen all four states with your own eyes (with latency and failureRate: 0.3).
2. Five test files are green (model, mapper, repository, notifier, widget).
3. After adding the …Remote implementation, the screen code and the notifier are unchanged — only one provider line.
4. grep -rEn "package:(flutter|http)" lib/domain finds nothing.
The third item is this branch's key check: if it holds, your layering works.
📚 Sources and documentation
- Case study: the Compass appofficialdocs.flutter.dev
A complete feature in real code — structure, DI and tests on separate pages.
- The Compass app source (GitHub)officialgithub.com
Worth opening for comparison while building your own feature — file names and folders.
- Riverpod: code generationofficialriverpod.dev
`@riverpod`, `part '*.g.dart'` and `build_runner watch` — the setup for the practice.
- Dart: parallel `.wait` on a record of futuresofficialapi.dart.dev
Since Dart 3.0, `(Future<A>, Future<B>).wait` → `Future<(A, B)>`; on failure it completes with `ParallelWaitError`.