Skip to main content

Walkthrough: DDD Wallet

Sample: examples/ddd_wallet · Needs: nothing but Dart

The todos sample hid data access behind a repository. This one goes further and applies Domain-Driven Design: the business rules live in pure Dart objects that know nothing about HTTP or storage, and everything else — the web layer, the database — sits at the edges depending inward.

The example is a wallet you can open, deposit into, and withdraw from. The money math is trivial on purpose; the point is the shape, not the arithmetic.

The dependency rule

DDD's core idea is one arrow: outer layers depend on inner layers, never the reverse.

interface/http   →   application   →   domain   ←   infrastructure
(controllers) (use cases) (rules) (storage impl)

The domain sits at the centre and imports nothing framework-specific. Everything points at it.

lib/
domain/ # pure Dart, no framework
money.dart # value object
wallet.dart # aggregate root
wallet_repository.dart # repository interface (owned by the domain)
wallet_exception.dart # domain error
application/
wallet_service.dart # use-case orchestration
infrastructure/
in_memory_wallet_repository.dart # a concrete repository
interface/http/
wallet_controller.dart # HTTP adapter + composition root
wallet_route.dart

Value object: Money

A value object is immutable, compared by value, and enforces its own invariants. Money can never be negative — that rule lives inside the type, so an invalid Money cannot exist anywhere in the program.

// lib/domain/money.dart
class Money {
final int cents;
const Money._(this.cents);

factory Money(int cents) {
if (cents < 0) throw const WalletException('Money cannot be negative');
return Money._(cents);
}

static const Money zero = Money._(0);

Money add(Money other) => Money(cents + other.cents);
Money subtract(Money other) => Money(cents - other.cents);
bool isLessThan(Money other) => cents < other.cents;

@override
bool operator ==(Object other) => other is Money && other.cents == cents;
@override
int get hashCode => cents.hashCode;
}

Because equality is by value, two Money(500) objects are equal. Because the constructor guards the invariant, no other code has to remember to check for negatives.

Aggregate root: Wallet

An aggregate root owns its state and only allows changes through its own behaviour. Notice there is no setBalance — the balance changes only via deposit and withdraw, and each guards the rules.

// lib/domain/wallet.dart
class Wallet {
final String id;
Money _balance;

Wallet({required this.id, Money balance = Money.zero}) : _balance = balance;

Money get balance => _balance;

void deposit(Money amount) {
if (amount == Money.zero) throw const WalletException('Deposit must be positive');
_balance = _balance.add(amount);
}

void withdraw(Money amount) {
if (amount == Money.zero) throw const WalletException('Withdrawal must be positive');
if (_balance.isLessThan(amount)) throw const WalletException('Insufficient funds');
_balance = _balance.subtract(amount);
}
}

The balance is a private field. Nothing outside the wallet can push it below zero, because nothing outside the wallet can touch it at all. That is the whole promise of an aggregate: it protects its own invariants.

The repository interface — owned by the domain

The domain declares what persistence it needs, not how it happens:

// lib/domain/wallet_repository.dart
abstract interface class WalletRepository {
Wallet? findById(String id);
void save(Wallet wallet);
}

This interface lives in the domain, but it is implemented out in infrastructure. That inversion is what keeps the arrow pointing inward.

Application service: orchestrate, don't decide

The service loads an aggregate, calls its behaviour, and saves it. It holds no rules — the rules are in Wallet.

// lib/application/wallet_service.dart
class WalletService {
final WalletRepository _repository;
WalletService(this._repository);

Wallet deposit(String id, int cents) {
final wallet = _require(id);
wallet.deposit(Money(cents)); // the rule lives in the wallet
_repository.save(wallet);
return wallet;
}

Wallet _require(String id) {
final wallet = _repository.findById(id);
if (wallet == null) throw WalletException('Wallet $id not found');
return wallet;
}
}

Read → act → save. That is the entire job of an application service.

Infrastructure: a concrete repository

// lib/infrastructure/in_memory_wallet_repository.dart
class InMemoryWalletRepository implements WalletRepository {
final Map<String, Wallet> _store = {};

@override
Wallet? findById(String id) => _store[id];
@override
void save(Wallet wallet) => _store[wallet.id] = wallet;
}

Swap this for a database-backed class and the domain and application layers do not change one line — they only ever saw the interface.

Interface layer: thin HTTP adapter + composition root

The controller is where the concrete pieces are finally wired together (the "composition root"), and where domain errors become HTTP status codes:

// lib/interface/http/wallet_controller.dart
final WalletService _service = WalletService(InMemoryWalletRepository());

class WalletController extends Controller {
Future<Response> deposit(Request req, String id) async {
await req.validate({'cents': 'required|numeric'});
return _guard(() => _service.deposit(id, int.parse(req.input('cents').toString())));
}

Response _guard(Wallet Function() action, [int status = 200]) {
try {
final wallet = action();
return Response.json({'id': wallet.id, 'balance': wallet.balance.cents}, status);
} on WalletException catch (e) {
final code = e.message.endsWith('not found') ? 404 : 422;
return Response.json({'message': e.message}, code);
}
}
}

The controller does three things and no more: validate input, call the service, and translate a WalletException into a 404 or 422. All the interesting decisions happened deeper in.

Why this pays off

Because the domain and application layers depend on nothing but pure Dart, the tests run instantly with no server and no database:

cd examples/ddd_wallet
dart test
  • money.dart and wallet.dart are tested by constructing objects and asserting on behaviour (deposit, overdraft, negatives).
  • wallet_service.dart is tested by injecting an in-memory repository.

What to take away

  • Value object — immutability plus a self-checked invariant (Money is never negative).
  • Aggregate root — expose behaviour, not setters; guard your own rules (Wallet never overdraws).
  • Repository interface in the domain, implemented in infrastructure — the inversion that keeps the dependency arrow pointing inward.
  • Application service orchestrates; it does not decide.
  • Thin interface layer maps requests to use cases and errors to status codes.

This is more structure than a small app needs — the counter proves that. Reach for it when the rules are the hard part of the problem, not the plumbing. For the conceptual background, see Domain-Driven Design.