On this page23 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. Rules and Storage Are Two Jobs
- 2. What Can Be Stored: Entity<Id>
- 3. The Contract: Repository<T, Id>
- 4. The Simplest Implementation: In Memory
- 5. The Real One: JsonFileRepository
- 6. A Domain Error for Broken Files
- 7. Services Receive Their Repositories
- 8. The async Ripple
- 9. One Place That Wires Everything: BudgetApp
- 10. The First Real CLI
- 11. A Real-World Example
- Code Examples
- Common Beginner Mistakes
- Best Practices
- Mini Challenge
- Practice Questions
- Practice Answers
- Quick Revision
- Key Takeaways
- What's Next?
Lecture 135 — Project Repository
| Category | 23 — Final Dart Project |
| Lecture | 135 of 140 |
| Estimated video length | 20 min |
| Target | Dart 3.12.2 |
| Level | Advanced |
Our services know the rules, but they also own the data — in maps that vanish the moment the program ends. A repository takes storage out of the services, so the same rules can run on memory today and on JSON files in
data/— our first real command-line app.
Contents
Prerequisites · Connection · Objectives · Lesson · Code Examples · Common Mistakes · Best Practices · Mini Challenge · Practice Questions · Answers · Takeaways
Every error message in this lecture is real. Each one was produced by running the code shown on Dart 3.12.2, so you will recognize it when you meet it yourself. The sessions were run on 2026-10-17;
demoputs its transactions in the current month and typed transactions get today's date, so only your dates will differ.
Prerequisites
Students should already understand, from Lectures 063, 066, 072–074, 088–089, 106–109, 113 and 130–134:
- interfaces,
implements, andabstract interface class(063, 130) - generic classes with a bounded type parameter,
T extends Bound(072–074) Future,asyncandawait(088–089)File,exists,readAsString,writeAsString,p.join,jsonDecodeandtoJson(106–109)main(List<String> args),exitCodeandstderr(113)- the project so far: models (133),
LedgerService,BudgetServiceanderrors.dart(134)
Connection With Previous Lectures
At the end of Lecture 134, LedgerService began like this:
final Map<String, Wallet> _wallets = {};
final List<Transaction> _transactions = [];Two problems follow. Everything Ali records disappears when main returns. And the service
is welded to its map: saving to a file would mean rewriting every method. The rules ("you
can't spend more than you have") have nothing to do with where wallets live. This lecture
pulls those two jobs apart.
Learning Objectives
By the end of this lecture you will be able to:
- Explain why business rules and storage belong in separate classes
- Describe stored items with a generic
Entity<Id>interface - Define a generic
Repository<T extends Entity<Id>, Id>contract - Implement it twice: in memory and as a JSON file with a lazy cache
- Wrap a low-level
FormatExceptionin a domainStorageException - Inject repositories into services and follow the
asyncripple through the code - Wire everything in one place with
BudgetApp, the composition root - Run the first real command-line version of the budget tracker
1. Rules and Storage Are Two Jobs
A repository is an object whose only job is to store and fetch items. The services ask it
"give me wallet ali" or "save this transaction" and never ask how.
Analogy — a school's records office. A teacher asks for the file of roll number 17; the clerk fetches it. Whether it sits in a steel cabinet or on a computer is the office's business, not the teacher's.
The project's dependency direction stays one-way: bin → services → repository → models.
| Create | Change |
|---|---|
lib/src/models/entity.dart | wallet.dart, transaction.dart, budget.dart |
lib/src/repository/repository.dart | errors.dart (+ StorageException) |
lib/src/repository/in_memory_repository.dart | ledger_service.dart, budget_service.dart |
lib/src/repository/json_file_repository.dart | lib/budget_tracker.dart (exports) |
lib/src/budget_app.dart | bin/budget_tracker.dart (the CLI) |
2. What Can Be Stored: Entity<Id>
A repository needs two things from every item: an id to find it by, and a way to turn it
into JSON. Create lib/src/models/entity.dart:
/// Anything a repository can store: it has an id and can become JSON.
abstract interface class Entity<Id> {
/// The value that identifies this item; no two items share it.
Id get id;
/// The item as a JSON-ready map.
Map<String, Object?> toJson();
}Wallet and Transaction already have a String id and a toJson() from Lecture 133, so they
only declare the promise. In lib/src/models/wallet.dart (and the same way in
transaction.dart):
final class Wallet implements Entity<String> {
// ...
@override
final String id;A field can satisfy an interface getter, and toJson() gets an @override too. Budget is
different: it has no id field — there is one budget per category, so the category is the
id. In lib/src/models/budget.dart:
final class Budget implements Entity<Category> {
// ...
/// A budget is identified by its category: one budget per category.
@override
Category get id => category;That is why Entity is generic: wallets are found by a String, budgets by a Category.
3. The Contract: Repository<T, Id>
Create lib/src/repository/repository.dart:
import '../models/entity.dart';
/// Stores items of type [T], each identified by a value of type [Id].
///
/// Services depend on this interface only, so the storage can change
/// (memory, JSON file, ...) without touching them.
abstract interface class Repository<T extends Entity<Id>, Id> {
/// Every stored item.
Future<List<T>> findAll();
/// The item with [id], or `null` if there is none.
Future<T?> findById(Id id);
/// Inserts [item], or replaces the stored item with the same id.
Future<void> save(T item);
/// Removes the item with [id], if it exists.
Future<void> delete(Id id);
}Two design choices matter:
- The bound
T extends Entity<Id>guarantees everyThas anidof typeId, so an implementation may writeitem.id. It also ties the two together:Budgetis anEntity<Category>, soRepository<Budget, String>is rejected. - Every method returns a
Future. Reading a file is asynchronous. Even if one implementation is instant, the contract must allow the slow ones.
4. The Simplest Implementation: In Memory
Create lib/src/repository/in_memory_repository.dart:
import '../models/entity.dart';
import 'repository.dart';
/// A [Repository] that keeps everything in a map; nothing is saved to disk.
class InMemoryRepository<T extends Entity<Id>, Id>
implements Repository<T, Id> {
InMemoryRepository([Iterable<T> items = const []]) {
for (final item in items) {
_items[item.id] = item;
}
}
final Map<Id, T> _items = {};
@override
Future<List<T>> findAll() async => List.unmodifiable(_items.values);
@override
Future<T?> findById(Id id) async => _items[id];
@override
Future<void> save(T item) async => _items[item.id] = item;
@override
Future<void> delete(Id id) async => _items.remove(id);
}It is the old service map, moved; async wraps each result in a Future. The optional
constructor list seeds data for tests and demos, and findAll returns an unmodifiable list.
5. The Real One: JsonFileRepository
Create lib/src/repository/json_file_repository.dart:
import 'dart:convert';
import 'dart:io';
import '../errors.dart';
import '../models/entity.dart';
import 'repository.dart';
/// Turns one decoded JSON object into an item.
typedef FromJson<T> = T Function(Map<String, Object?> json);
/// A [Repository] that saves its items as a JSON list in [file].
///
/// The file is read once, on first use; every change rewrites it.
class JsonFileRepository<T extends Entity<Id>, Id>
implements Repository<T, Id> {
JsonFileRepository(this.file, this._fromJson);
/// The JSON file that holds the items.
final File file;
final FromJson<T> _fromJson;
Map<Id, T>? _cache;
@override
Future<List<T>> findAll() async => List.unmodifiable((await _items()).values);
@override
Future<T?> findById(Id id) async => (await _items())[id];
@override
Future<void> save(T item) async {
final items = await _items();
items[item.id] = item;
await _write(items);
}
@override
Future<void> delete(Id id) async {
final items = await _items();
if (items.remove(id) != null) await _write(items);
}
Future<Map<Id, T>> _items() async => _cache ??= await _read();
Future<Map<Id, T>> _read() async {
if (!await file.exists()) return {};
try {
final decoded = jsonDecode(await file.readAsString());
if (decoded is! List) throw const FormatException('expected a list');
final items = decoded.map(_decodeOne);
return {for (final item in items) item.id: item};
} on FormatException catch (e) {
throw StorageException(file.path, e.message);
}
}
T _decodeOne(Object? entry) => switch (entry) {
Map<String, Object?> json => _fromJson(json),
_ => throw FormatException('expected an object, got $entry'),
};
Future<void> _write(Map<Id, T> items) async {
await file.parent.create(recursive: true);
final text = const JsonEncoder.withIndent(
' ',
).convert(items.values.toList());
await file.writeAsString('$text\n');
}
}Piece by piece:
FromJson<T>is a typedef for "a function that builds aTfrom a map". The repository can't know how to build a wallet, so we pass itWallet.fromJson— a constructor torn off as a value._cache ??= await _read()reads the file only the first time_items()is called. After that, the map in memory is the truth, and everysaveordeletewrites it back._readtreats a missing file as "no items yet";_decodeOnechecks each entry is a JSON object before calling_fromJson._writecreates thedata/folder if needed and letsJsonEncoder.withIndentcall each item'stoJson()for us (Lecture 109).
6. A Domain Error for Broken Files
A damaged file produces a FormatException, from jsonDecode or from a model's fromJson.
_read catches it and throws our own type instead, added to lib/src/errors.dart:
/// Thrown when a data file cannot be read.
final class StorageException extends BudgetException {
StorageException(this.path, String reason)
: super('Could not read "$path": $reason');
final String path;
}Every on BudgetException handler already catches it, and the message names the file to fix.
7. Services Receive Their Repositories
Now the services stop owning data. In lib/src/services/ledger_service.dart the fields become
constructor parameters, and every public method returns a Future:
class LedgerService {
LedgerService(this._wallets, this._transactions, {DateTime Function()? clock})
: _clock = clock ?? DateTime.now;
final Repository<Wallet, String> _wallets;
final Repository<Transaction, String> _transactions;
final DateTime Function() _clock;
/// Every wallet.
Future<List<Wallet>> wallets() => _wallets.findAll();The rules don't change — only how data is reached:
/// Finds a wallet or throws [WalletNotFoundException].
Future<Wallet> walletById(String id) async =>
await _wallets.findById(id) ?? (throw WalletNotFoundException(id));The getters wallets and transactions became methods, wallets() and transactions():
a getter that returns a Future reads like a plain value, which would hide the waiting.
BudgetService changes the same way (lib/src/services/budget_service.dart):
BudgetService(this._budgets, this._ledger);
final Repository<Budget, Category> _budgets;
final LedgerService _ledger;Neither service mentions files or maps.
8. The async Ripple
Once a method returns a Future, every caller must change too. Run dart analyze before
touching bin/ and the old services demo from Lecture 134 lights up (excerpt — 11 errors in
total):
error - bin/budget_tracker.dart:6:32 - 2 positional arguments expected by 'LedgerService.new', but 0 found. Try adding the missing arguments. - not_enough_positional_arguments
error - bin/budget_tracker.dart:52:24 - The type 'Future<List<Wallet>> Function()' used in the 'for' loop must implement 'Iterable'. - for_in_of_invalid_type
error - bin/budget_tracker.dart:99:32 - The getter 'month' isn't defined for the type 'Future<MonthlyReport>'. Try importing the library that defines 'month', correcting the name to the name of an existing getter, or defining a getter or field named 'month'. - undefined_getterEach error marks a place that needs an await. Notice what is missing — lines like ledger.openWallet(...); with no await produce no error at all.
A forgotten await that the compiler can't see is the dangerous kind. We will turn on a lint
that catches it in Lecture 139.
9. One Place That Wires Everything: BudgetApp
Someone has to decide which repositories the services get. Create lib/src/budget_app.dart:
import 'dart:io';
import 'package:path/path.dart' as p;
import 'models/budget.dart';
import 'models/category.dart';
import 'models/transaction.dart';
import 'models/wallet.dart';
import 'repository/in_memory_repository.dart';
import 'repository/json_file_repository.dart';
import 'services/budget_service.dart';
import 'services/ledger_service.dart';
/// Wires repositories and services together.
class BudgetApp {
BudgetApp._(this.ledger, this.budgets);
/// An app that keeps everything in memory (handy for demos and tests).
factory BudgetApp.inMemory({DateTime Function()? clock}) {
final ledger = LedgerService(
InMemoryRepository<Wallet, String>(),
InMemoryRepository<Transaction, String>(),
clock: clock,
);
final budgets = BudgetService(
InMemoryRepository<Budget, Category>(),
ledger,
);
return BudgetApp._(ledger, budgets);
}
/// An app that saves its data as JSON files inside [dataDir].
factory BudgetApp.files(String dataDir, {DateTime Function()? clock}) {
File file(String name) => File(p.join(dataDir, name));
final ledger = LedgerService(
JsonFileRepository<Wallet, String>(file('wallets.json'), Wallet.fromJson),
JsonFileRepository<Transaction, String>(
file('transactions.json'),
Transaction.fromJson,
),
clock: clock,
);
final budgets = BudgetService(
JsonFileRepository<Budget, Category>(
file('budgets.json'),
Budget.fromJson,
),
ledger,
);
return BudgetApp._(ledger, budgets);
}
final LedgerService ledger;
final BudgetService budgets;
}This is composition (Lecture 066): the composition root. The two factories differ only in
storage. Finally, add the new files to
lib/budget_tracker.dart:
export 'src/budget_app.dart';
export 'src/models/entity.dart';
export 'src/repository/in_memory_repository.dart';
export 'src/repository/json_file_repository.dart';
export 'src/repository/repository.dart';10. The First Real CLI
Replace bin/budget_tracker.dart with a program that reads a command from args. Its shape
(excerpt — the full file is about 200 lines):
Future<void> main(List<String> args) async {
var app = BudgetApp.files('data');
var ledger = app.ledger;
var budgets = app.budgets;
if (args.isEmpty) {
printHelp();
return;
}
try {
if (args[0] == 'wallets') {
// ... print each wallet with its balance ...
} else if (args[0] == 'add-wallet') {
if (args.length < 4) {
print('Usage: add-wallet <id> <owner> <name> [opening]');
exitCode = 64;
return;
}
var opening = Money.zero;
if (args.length > 4) {
opening = Money.tryParse(args[4]) ?? Money(-1);
if (opening == Money(-1)) throw InvalidAmountException(args[4]);
}
// ... income, spend, transfer, budget, report, history, demo ...
} else {
print('Unknown command "${args[0]}".');
printHelp();
exitCode = 64;
}
} on BudgetException catch (e) {
stderr.writeln('Error: $e');
exitCode = 1;
}
}One on BudgetException turns every rule violation — and every broken file — into a clean
Error: line on stderr with exit code 1; wrong usage gets 64.
This is a deliberate first draft. It works, but look closer: the usage check is repeated in every branch,
Money(-1)is a "magic" value standing in fornull,varis everywhere, and one real bug hides indemo. Leave it exactly as it is — Lecture 139 analyzes and refactors this file.
11. A Real-World Example
Run from the project folder (the data/ folder is created there):
$ dart run budget_tracker demo
Demo data created for Ali, Sara and Zayan.
$ dart run budget_tracker wallets
ali Ali Pocket money Rs 3300.00
sara Sara Bank card Rs 1200.00
zayan Zayan Cash Rs 1400.00
$ dart run budget_tracker spend zayan 300 food "samosas"
Recorded t0007: 2026-10-17 Food expense (samosas) Rs 300.00
$ dart run budget_tracker spend zayan 5000 fun
Error: Wallet "zayan" has only Rs 1100.00, cannot take Rs 5000.00.
$ dart run budget_tracker spend bilal 10 food
Error: No wallet with id "bilal".
$ dart run budget_tracker report
Report for 2026-10
Income: Rs 6500.00
Spent: Rs 2900.00
Net: Rs 3600.00
Food Rs 1500.00 / Rs 1500.00 Near limit
Transport Rs 600.00 / Rs 500.00 OVER BUDGET
Books Rs 800.00 / no limit -
Fun Rs 0.00 / Rs 1000.00 OK
$ ls data
budgets.json
transactions.json
wallets.json
$ head -9 data/transactions.json
[
{
"type": "income",
"id": "t0001",
"walletId": "ali",
"amount": 500000,
"date": "2026-10-01T00:00:00.000",
"source": "Allowance"
},Each command is a separate run, yet Zayan's samosas survive into the next one. Money is
stored as cents (500000 = Rs 5000.00). dart analyze reports No issues found!.
Code Examples
Put each example in the project's example/ folder and run it from the project root, e.g.
dart run example/ex1.dart. Examples that use files write to example_data/.
Example 1 — Save and Find
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final wallets = InMemoryRepository<Wallet, String>();
await wallets.save(
const Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money'),
);
await wallets.save(const Wallet(id: 'zayan', owner: 'Zayan', name: 'Cash'));
print(await wallets.findById('ali'));
print(await wallets.findById('bilal'));
print(await wallets.findAll());
}Wallet(ali, Ali, Pocket money)
null
[Wallet(ali, Ali, Pocket money), Wallet(zayan, Zayan, Cash)]Example 2 — save Replaces, delete Is Safe
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final wallets = InMemoryRepository<Wallet, String>();
const ali = Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
await wallets.save(ali);
await wallets.save(ali.copyWith(name: 'Cash'));
print(await wallets.findAll());
await wallets.delete('ali');
await wallets.delete('ali'); // Deleting twice is harmless.
print(await wallets.findAll());
}[Wallet(ali, Ali, Cash)]
[]Same id, so the second save replaced the first.
Example 3 — Budgets Keyed by Category
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final budgets = InMemoryRepository<Budget, Category>();
await budgets.save(
Budget(category: Category.food, monthlyLimit: Money(150000)),
);
await budgets.save(
Budget(category: Category.food, monthlyLimit: Money(200000)),
);
print(await budgets.findAll());
print(await budgets.findById(Category.food));
print(await budgets.findById(Category.books));
}[Budget(Food: Rs 2000.00)]
Budget(Food: Rs 2000.00)
nullOne budget per category comes for free: the id is the category.
Example 4 — Services Over Memory
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final app = BudgetApp.inMemory(clock: () => DateTime(2026, 10, 15));
await app.ledger.openWallet(id: 'sara', owner: 'Sara', name: 'Bank card');
final income = await app.ledger.recordIncome(
'sara',
Money(300000),
source: 'Scholarship',
);
print(income.describe());
print((await app.ledger.balanceOf('sara')).format());
}2026-10-15 Income from Scholarship Rs 3000.00
Rs 3000.00Example 5 — Data Survives Between Runs
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final app = BudgetApp.files('example_data');
try {
await app.ledger.openWallet(id: 'sara', owner: 'Sara', name: 'Bank card');
print('Opened Sara\'s wallet.');
} on DuplicateWalletException catch (e) {
print('Second run: $e');
}
print(await app.ledger.wallets());
}First run, then second run:
Opened Sara's wallet.
[Wallet(sara, Sara, Bank card)]Second run: A wallet with id "sara" already exists.
[Wallet(sara, Sara, Bank card)]Example 6 — A Broken File Becomes a StorageException
import 'dart:io';
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final file = File('example_data/broken.json');
await file.writeAsString('{"id": "ali"}');
final wallets = JsonFileRepository<Wallet, String>(file, Wallet.fromJson);
try {
await wallets.findAll();
} on BudgetException catch (e) {
print('${e.runtimeType}: $e');
}
}StorageException: Could not read "example_data/broken.json": expected a listExample 7 — Your Own Repository: A Logging Wrapper
Any class that keeps the contract can be plugged in. This one wraps another repository:
import 'package:budget_tracker/budget_tracker.dart';
/// Wraps any repository and prints every save.
class LoggingRepository<T extends Entity<Id>, Id> implements Repository<T, Id> {
LoggingRepository(this._inner);
final Repository<T, Id> _inner;
@override
Future<List<T>> findAll() => _inner.findAll();
@override
Future<T?> findById(Id id) => _inner.findById(id);
@override
Future<void> save(T item) {
print('saving ${item.id}');
return _inner.save(item);
}
@override
Future<void> delete(Id id) => _inner.delete(id);
}
Future<void> main() async {
final ledger = LedgerService(
LoggingRepository(InMemoryRepository<Wallet, String>()),
LoggingRepository(InMemoryRepository<Transaction, String>()),
);
await ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
await ledger.recordIncome('ali', Money(50000), source: 'Allowance');
print((await ledger.balanceOf('ali')).format());
}saving ali
saving t0001
Rs 500.00LedgerService never noticed.
Example 8 — Same Code, Two Storages
import 'package:budget_tracker/budget_tracker.dart';
Future<void> tryApp(String label, BudgetApp app) async {
if ((await app.ledger.wallets()).isEmpty) {
await app.ledger.openWallet(id: 'zayan', owner: 'Zayan', name: 'Cash');
}
await app.ledger.recordIncome('zayan', Money(10000), source: 'Uncle');
print('$label: ${(await app.ledger.balanceOf('zayan')).format()}');
}
Future<void> main() async {
await tryApp('memory', BudgetApp.inMemory());
await tryApp('files', BudgetApp.files('example_data'));
}Run it twice (from an empty example_data/):
memory: Rs 100.00
files: Rs 100.00memory: Rs 100.00
files: Rs 200.00Common Beginner Mistakes
1. Forgetting await
final balance = app.ledger.balanceOf('ali');
print('Balance: $balance');Balance: Instance of 'Future<int>'You printed the promise, not the money. (It says int because the extension type Money is
an int at run time.) Write await app.ledger.balanceOf('ali').
2. A type that is not an Entity, or the wrong Id
// final names = InMemoryRepository<String, String>(); // ✗
// final budgets = InMemoryRepository<Budget, String>(); // ✗error - example/bound_mistake.dart:4:36 - 'String' doesn't conform to the bound 'Entity<String>' of the type parameter 'T'. Try using a type that is or is a subclass of 'Entity<String>'. - type_argument_not_matching_bounds
error - example/bound_mistake.dart:5:38 - 'Budget' doesn't conform to the bound 'Entity<String>' of the type parameter 'T'. Try using a type that is or is a subclass of 'Entity<String>'. - type_argument_not_matching_boundsA Budget is an Entity<Category>, so its repository is Repository<Budget, Category>.
3. Two repositories for the same file
final first = JsonFileRepository<Wallet, String>(file, Wallet.fromJson);
final second = JsonFileRepository<Wallet, String>(file, Wallet.fromJson);
print(await second.findAll()); // second reads the file now: empty
await first.save(const Wallet(id: 'ali', owner: 'Ali', name: 'Cash'));
await second.save(const Wallet(id: 'sara', owner: 'Sara', name: 'Card'));The file ends up holding only Sara — Ali is lost: second trusted its stale cache and
overwrote the file. Create one repository per file, as BudgetApp does.
4. Making the service know about files
File(...) or jsonDecode inside LedgerService welds it to storage again; only BudgetApp
chooses the kind.
Best Practices
- Keep business rules in services and storage in repositories; neither imports the other's details.
- Depend on the interface (
Repository<T, Id>), never on a concrete class, inside services. - Make a storage contract asynchronous from the start.
- Wire concrete classes in one composition root (
BudgetApp). - Translate low-level errors (
FormatException) into domain errors with useful context. - Use an in-memory implementation for demos and tests — no files to clean up.
Mini Challenge
Build a LedgerService by hand where the wallet repository starts with data (Ali with no
opening balance, Sara with Rs 2000), then transfer Rs 250 from Sara to Ali and print both
balances.
Ali: Rs 250.00
Sara: Rs 1750.00Try it yourself before opening the solution.
Show solution
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final wallets = InMemoryRepository<Wallet, String>([
const Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money'),
Wallet(
id: 'sara',
owner: 'Sara',
name: 'Bank card',
openingBalance: Money(200000),
),
]);
final ledger = LedgerService(
wallets,
InMemoryRepository<Transaction, String>(),
);
await ledger.transfer('sara', 'ali', Money(25000), note: 'lunch money');
for (final wallet in await ledger.wallets()) {
print('${wallet.owner}: ${(await ledger.balanceOf(wallet.id)).format()}');
}
}Ali: Rs 250.00
Sara: Rs 1750.00Tests will use the same seeding trick in Lecture 138.
Practice Questions
Work through these in order — they run from beginner to application to challenge.
Write programs in example/; CLI questions start from dart run budget_tracker demo.
Beginner
- Create an
InMemoryRepository<Wallet, String>that starts with Ali, Sara and Zayan (namesPocket money,Bank card,Cash). Print how many wallets it holds and Sara's wallet name. - In Example 5, replace
BudgetApp.files('example_data')withBudgetApp.inMemory()and run it twice. What does each run print, and why?
Application
- After
demo, editdata/wallets.jsonby hand so Sara'sopeningBalanceis250000. What doeswalletsprint for Sara? - Write
oopsintodata/budgets.json. Which ofreportandwalletsfails, and why does the other still work? - Prove that
JsonFileRepositoryreads its file only once: write one wallet to a file, callfindAll(), delete the file, callfindAll()again. - Why does
InMemoryRepository<Budget, String>()fail to compile, whenBudgetdoes implementEntity? - Write a generic function
clearAllthat deletes every item of any repository, and use it on a budget repository holding food and fun limits.
Challenge
- Through the CLI only: open Bilal's
Piggy bankwith Rs 250, try to open it again, and explain the exit code. - Sketch a
CsvFileRepositoryfor transactions: write the class header and the four methods withthrow UnimplementedError()bodies, then describe what reading would do.
Practice Answers
Question 1 — Solution
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final wallets = InMemoryRepository<Wallet, String>([
const Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money'),
const Wallet(id: 'sara', owner: 'Sara', name: 'Bank card'),
const Wallet(id: 'zayan', owner: 'Zayan', name: 'Cash'),
]);
print((await wallets.findAll()).length);
print((await wallets.findById('sara'))?.name);
}3
Bank cardQuestion 2 — Solution
Both runs print the same thing:
Opened Sara's wallet.
[Wallet(sara, Sara, Bank card)]The in-memory repository starts empty every run and saves nothing to disk.
Question 3 — Solution
sara Sara Bank card Rs 1700.00Opening balance Rs 2500 minus the Rs 800 books expense. The file is the data.
Question 4 — Solution
$ dart run budget_tracker report
Error: Could not read "data/budgets.json": Unexpected characterwallets still works: it never touches the budget repository, and each repository reads its
file lazily, on first use.
Question 5 — Solution
import 'dart:io';
import 'package:budget_tracker/budget_tracker.dart';
Future<void> main() async {
final file = File('example_data/cache.json');
await file.writeAsString(
'[{"id": "ali", "owner": "Ali", "name": "Cash", "openingBalance": 0}]',
);
final wallets = JsonFileRepository<Wallet, String>(file, Wallet.fromJson);
print(await wallets.findAll());
await file.delete();
print(await wallets.findAll());
print(await file.exists());
}[Wallet(ali, Ali, Cash)]
[Wallet(ali, Ali, Cash)]
falseThe second call came from _cache; ??= skipped _read().
Question 6 — Solution
With Id = String, the bound T extends Entity<Id> demands an Entity<String>, but Budget
is an Entity<Category>. Use InMemoryRepository<Budget, Category>().
Question 7 — Solution
import 'package:budget_tracker/budget_tracker.dart';
/// Deletes every item in [repository].
Future<void> clearAll<T extends Entity<Id>, Id>(
Repository<T, Id> repository,
) async {
for (final item in await repository.findAll()) {
await repository.delete(item.id);
}
}
Future<void> main() async {
final budgets = InMemoryRepository<Budget, Category>([
Budget(category: Category.food, monthlyLimit: Money(150000)),
Budget(category: Category.fun, monthlyLimit: Money(100000)),
]);
await clearAll(budgets);
print(await budgets.findAll());
}[]findAll returns a separate list, so deleting while looping is safe.
Question 8 — Solution
$ dart run budget_tracker add-wallet bilal Bilal "Piggy bank" 250
Opened wallet "bilal" for Bilal.
$ dart run budget_tracker add-wallet bilal Bilal "Piggy bank" 250
Error: A wallet with id "bilal" already exists.The second run finds Bilal in data/wallets.json, openWallet throws
DuplicateWalletException, and main sets exit code 1 — a failed command, not wrong usage.
Question 9 — Solution
A sketch only — the class keeps the contract, the bodies come later:
import 'dart:io';
import 'package:budget_tracker/budget_tracker.dart';
/// Saves transactions as CSV lines: type,id,walletId,cents,date,...
class CsvFileRepository implements Repository<Transaction, String> {
CsvFileRepository(this.file);
final File file;
@override
Future<List<Transaction>> findAll() => throw UnimplementedError();
@override
Future<Transaction?> findById(String id) => throw UnimplementedError();
@override
Future<void> save(Transaction item) => throw UnimplementedError();
@override
Future<void> delete(String id) => throw UnimplementedError();
}dart analyze reports No issues found!, so LedgerService(CsvFileRepository(file), ...)
would already compile. _read would split each line on ,, rebuild the right Transaction
subclass from its first column (income, expense, transfer) and throw StorageException
for a bad line — the same job fromJson does for JSON.
Quick Revision
- Which two jobs did
LedgerServicedo in Lecture 134, and which one moved out? - What two things does
Entity<Id>require? - Why is
BudgetanEntity<Category>and not anEntity<String>? - What does the bound
T extends Entity<Id>allow an implementation to write? - Why does every
Repositorymethod return aFuture? - When does
JsonFileRepositoryread its file? - Why does
_readturn aFormatExceptioninto aStorageException? - What is the job of
BudgetApp?
Key Takeaways
- A repository stores and fetches items; services keep only the rules.
Entity<Id>gives every stored item anidandtoJson(); the id type can differ.Repository<T extends Entity<Id>, Id>is anabstract interface classwith async methods.InMemoryRepositoryis a map;JsonFileRepositoryadds a lazy??=cache and rewrites the file on every change.- A broken file becomes a
StorageException, caught by the existingon BudgetException. - Making services
asyncripples to every caller; missingawaits that compile are the risky ones. BudgetApp.inMemory()andBudgetApp.files('data')wire the same services to different storage.- The first CLI saves to
data/and works — rough edges and all, until Lecture 139.
What's Next?
Lecture 136 — Collections & Generics in the Project. The data now lives safely in
repositories, but the services still walk through it with hand-written for loops, continue
and counter maps. Next we replace those loops with collection pipelines and our own generic
helpers — groupBy, money sums and repository queries — so the report shrinks to a few clear
lines.