Watch the video (20 min)
On this page23 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. Rules and Storage Are Two Jobs
  5. 2. What Can Be Stored: Entity<Id>
  6. 3. The Contract: Repository<T, Id>
  7. 4. The Simplest Implementation: In Memory
  8. 5. The Real One: JsonFileRepository
  9. 6. A Domain Error for Broken Files
  10. 7. Services Receive Their Repositories
  11. 8. The async Ripple
  12. 9. One Place That Wires Everything: BudgetApp
  13. 10. The First Real CLI
  14. 11. A Real-World Example
  15. Code Examples
  16. Common Beginner Mistakes
  17. Best Practices
  18. Mini Challenge
  19. Practice Questions
  20. Practice Answers
  21. Quick Revision
  22. Key Takeaways
  23. What's Next?

Lecture 135 — Project Repository

Category23 — Final Dart Project
Lecture135 of 140
Estimated video length20 min
TargetDart 3.12.2
LevelAdvanced

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; demo puts 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, and abstract interface class (063, 130)
  • generic classes with a bounded type parameter, T extends Bound (072–074)
  • Future, async and await (088–089)
  • File, exists, readAsString, writeAsString, p.join, jsonDecode and toJson (106–109)
  • main(List<String> args), exitCode and stderr (113)
  • the project so far: models (133), LedgerService, BudgetService and errors.dart (134)

Connection With Previous Lectures

At the end of Lecture 134, LedgerService began like this:

dart
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 FormatException in a domain StorageException
  • Inject repositories into services and follow the async ripple 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.

CreateChange
lib/src/models/entity.dartwallet.dart, transaction.dart, budget.dart
lib/src/repository/repository.darterrors.dart (+ StorageException)
lib/src/repository/in_memory_repository.dartledger_service.dart, budget_service.dart
lib/src/repository/json_file_repository.dartlib/budget_tracker.dart (exports)
lib/src/budget_app.dartbin/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:

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):

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:

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:

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 every T has an id of type Id, so an implementation may write item.id. It also ties the two together: Budget is an Entity<Category>, so Repository<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:

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:

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 a T from a map". The repository can't know how to build a wallet, so we pass it Wallet.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 every save or delete writes it back.
  • _read treats a missing file as "no items yet"; _decodeOne checks each entry is a JSON object before calling _fromJson.
  • _write creates the data/ folder if needed and lets JsonEncoder.withIndent call each item's toJson() 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:

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:

dart
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:

dart
  /// 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):

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):

output
  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_getter

Each 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:

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:

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):

dart
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 for null, var is everywhere, and one real bug hides in demo. 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):

output
$ 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

dart
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());
}
output
Wallet(ali, Ali, Pocket money)
null
[Wallet(ali, Ali, Pocket money), Wallet(zayan, Zayan, Cash)]

Example 2 — save Replaces, delete Is Safe

dart
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());
}
output
[Wallet(ali, Ali, Cash)]
[]

Same id, so the second save replaced the first.

Example 3 — Budgets Keyed by Category

dart
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));
}
output
[Budget(Food: Rs 2000.00)]
Budget(Food: Rs 2000.00)
null

One budget per category comes for free: the id is the category.

Example 4 — Services Over Memory

dart
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());
}
output
2026-10-15  Income from Scholarship  Rs 3000.00
Rs 3000.00

Example 5 — Data Survives Between Runs

dart
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:

output
Opened Sara's wallet.
[Wallet(sara, Sara, Bank card)]
output
Second run: A wallet with id "sara" already exists.
[Wallet(sara, Sara, Bank card)]

Example 6 — A Broken File Becomes a StorageException

dart
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');
  }
}
output
StorageException: Could not read "example_data/broken.json": expected a list

Example 7 — Your Own Repository: A Logging Wrapper

Any class that keeps the contract can be plugged in. This one wraps another repository:

dart
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());
}
output
saving ali
saving t0001
Rs 500.00

LedgerService never noticed.

Example 8 — Same Code, Two Storages

dart
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/):

output
memory: Rs 100.00
files: Rs 100.00
output
memory: Rs 100.00
files: Rs 200.00

Common Beginner Mistakes

1. Forgetting await

dart
final balance = app.ledger.balanceOf('ali');
print('Balance: $balance');
output
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

dart
// final names = InMemoryRepository<String, String>(); // ✗
// final budgets = InMemoryRepository<Budget, String>(); // ✗
output
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_bounds

A Budget is an Entity<Category>, so its repository is Repository<Budget, Category>.

3. Two repositories for the same file

dart
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.

output
Ali: Rs 250.00
Sara: Rs 1750.00

Try it yourself before opening the solution.

Show solution
dart
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()}');
  }
}
output
Ali: Rs 250.00
Sara: Rs 1750.00

Tests 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

  1. Create an InMemoryRepository<Wallet, String> that starts with Ali, Sara and Zayan (names Pocket money, Bank card, Cash). Print how many wallets it holds and Sara's wallet name.
  2. In Example 5, replace BudgetApp.files('example_data') with BudgetApp.inMemory() and run it twice. What does each run print, and why?

Application

  1. After demo, edit data/wallets.json by hand so Sara's openingBalance is 250000. What does wallets print for Sara?
  2. Write oops into data/budgets.json. Which of report and wallets fails, and why does the other still work?
  3. Prove that JsonFileRepository reads its file only once: write one wallet to a file, call findAll(), delete the file, call findAll() again.
  4. Why does InMemoryRepository<Budget, String>() fail to compile, when Budget does implement Entity?
  5. Write a generic function clearAll that deletes every item of any repository, and use it on a budget repository holding food and fun limits.

Challenge

  1. Through the CLI only: open Bilal's Piggy bank with Rs 250, try to open it again, and explain the exit code.
  2. Sketch a CsvFileRepository for transactions: write the class header and the four methods with throw UnimplementedError() bodies, then describe what reading would do.

Practice Answers

Question 1 — Solution

dart
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);
}
output
3
Bank card

Question 2 — Solution

Both runs print the same thing:

output
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

output
sara    Sara    Bank card       Rs 1700.00

Opening balance Rs 2500 minus the Rs 800 books expense. The file is the data.

Question 4 — Solution

output
$ dart run budget_tracker report
Error: Could not read "data/budgets.json": Unexpected character

wallets still works: it never touches the budget repository, and each repository reads its file lazily, on first use.

Question 5 — Solution

dart
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());
}
output
[Wallet(ali, Ali, Cash)]
[Wallet(ali, Ali, Cash)]
false

The 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

dart
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());
}
output
[]

findAll returns a separate list, so deleting while looping is safe.

Question 8 — Solution

output
$ 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:

dart
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

  1. Which two jobs did LedgerService do in Lecture 134, and which one moved out?
  2. What two things does Entity<Id> require?
  3. Why is Budget an Entity<Category> and not an Entity<String>?
  4. What does the bound T extends Entity<Id> allow an implementation to write?
  5. Why does every Repository method return a Future?
  6. When does JsonFileRepository read its file?
  7. Why does _read turn a FormatException into a StorageException?
  8. 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 an id and toJson(); the id type can differ.
  • Repository<T extends Entity<Id>, Id> is an abstract interface class with async methods.
  • InMemoryRepository is a map; JsonFileRepository adds a lazy ??= cache and rewrites the file on every change.
  • A broken file becomes a StorageException, caught by the existing on BudgetException.
  • Making services async ripples to every caller; missing awaits that compile are the risky ones.
  • BudgetApp.inMemory() and BudgetApp.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.


Video lecture

Subscribe
Placeholder video — the real recording isn't linked yet.