replay 1.1.0
replay: ^1.1.0 copied to clipboard
Event Sourcing framework for Dart to build auditable apps with immutable events.
import 'package:replay/replay.dart';
class BankState {
final Map<String, int> balanceByAccountName;
const BankState({this.balanceByAccountName = const {}});
@override
String toString() {
return '$BankState(balanceByAccount: $balanceByAccountName)';
}
}
abstract interface class BankEvent {}
class BalanceSetEvent implements BankEvent {
final String accountName;
final int balance;
const BalanceSetEvent({required this.accountName, required this.balance});
@override
String toString() {
return '$BalanceSetEvent(accountName: $accountName, balance: $balance)';
}
}
class BalanceUnsetEvent implements BankEvent {
final String accountName;
const BalanceUnsetEvent({required this.accountName});
@override
String toString() {
return '$BalanceUnsetEvent(accountName: $accountName)';
}
}
class BalanceSetEventReducer
implements EventReducer<BalanceSetEvent, BankState> {
@override
BankState reduce(BalanceSetEvent event, BankState state) {
return BankState(
balanceByAccountName: {
...state.balanceByAccountName,
event.accountName: event.balance,
},
);
}
}
class BalanceUnsetEventReducer
implements EventReducer<BalanceUnsetEvent, BankState> {
@override
BankState reduce(BalanceUnsetEvent event, BankState state) {
return BankState(
balanceByAccountName: {
for (final MapEntry(key: accountName, value: balance)
in state.balanceByAccountName.entries)
if (accountName != event.accountName) accountName: balance,
},
);
}
}
abstract interface class BankCommand {}
class OpenAccountCommand implements BankCommand {
final String accountName;
final int initialBalance;
const OpenAccountCommand({
required this.accountName,
required this.initialBalance,
});
@override
String toString() {
return '$OpenAccountCommand(accountName: $accountName, initialBalance: $initialBalance)';
}
}
class CloseAccountCommand implements BankCommand {
final String accountName;
const CloseAccountCommand({required this.accountName});
@override
String toString() {
return '$CloseAccountCommand(accountName: $accountName)';
}
}
class TransferMoneyCommand implements BankCommand {
final String sourceAccountName;
final String targetAccountName;
final int amount;
const TransferMoneyCommand({
required this.sourceAccountName,
required this.targetAccountName,
required this.amount,
});
@override
String toString() {
return '$TransferMoneyCommand(sourceAccountName: $sourceAccountName, targetAccountName: $targetAccountName, amount: $amount)';
}
}
class OpenAccountCommandDecider
implements CommandDecider<OpenAccountCommand, BankEvent, BankState> {
@override
Iterable<BankEvent> decide(
OpenAccountCommand command,
BankState state,
) sync* {
if (state.balanceByAccountName.containsKey(command.accountName)) {
throw InvalidCommandException(
"Account '${command.accountName}' already exists",
);
}
if (command.initialBalance < 0) {
throw InvalidCommandException('Balance must not be negative');
}
yield BalanceSetEvent(
accountName: command.accountName,
balance: command.initialBalance,
);
}
}
class CloseAccountCommandDecider
implements CommandDecider<CloseAccountCommand, BankEvent, BankState> {
@override
Iterable<BankEvent> decide(
CloseAccountCommand command,
BankState state,
) sync* {
if (!state.balanceByAccountName.containsKey(command.accountName)) {
throw InvalidCommandException(
"Account '${command.accountName}' doesn't exist",
);
}
yield BalanceUnsetEvent(accountName: command.accountName);
}
}
class TransferMoneyCommandDecider
implements CommandDecider<TransferMoneyCommand, BankEvent, BankState> {
@override
Iterable<BankEvent> decide(
TransferMoneyCommand command,
BankState state,
) sync* {
final sourceBalance = state.balanceByAccountName[command.sourceAccountName];
if (sourceBalance == null) {
throw InvalidCommandException(
"Source account '${command.sourceAccountName}' doesn't exist",
);
}
if (sourceBalance < command.amount) {
throw InvalidCommandException(
"Balance of account '${command.sourceAccountName}' is insufficient",
);
}
final targetBalance = state.balanceByAccountName[command.targetAccountName];
if (targetBalance == null) {
throw InvalidCommandException(
"Target account '${command.targetAccountName}' doesn't exist",
);
}
yield BalanceSetEvent(
accountName: command.sourceAccountName,
balance: sourceBalance - command.amount,
);
yield BalanceSetEvent(
accountName: command.targetAccountName,
balance: targetBalance + command.amount,
);
}
}
abstract interface class BankOption {}
class OpenAccountOption implements BankOption {
@override
String toString() {
return '$OpenAccountOption()';
}
}
class OpenAccountOptionFinder
implements OptionFinder<OpenAccountOption, BankState> {
@override
Iterable<OpenAccountOption> find(BankState state) sync* {
yield OpenAccountOption();
}
}
class CloseAccountOption implements BankOption {
final List<String> accountNames;
const CloseAccountOption({required this.accountNames});
@override
String toString() {
return '$CloseAccountOption(accountNames: $accountNames)';
}
}
class CloseAccountOptionFinder
implements OptionFinder<CloseAccountOption, BankState> {
@override
Iterable<CloseAccountOption> find(BankState state) sync* {
yield CloseAccountOption(
accountNames: state.balanceByAccountName.keys.toList(),
);
}
}
class TransferMoneyOption implements BankOption {
final String sourceAccountName;
final List<String> targetAccountNames;
final int maxAmount;
const TransferMoneyOption({
required this.sourceAccountName,
required this.targetAccountNames,
required this.maxAmount,
});
@override
String toString() {
return '$TransferMoneyOption(sourceAccountName: $sourceAccountName, targetAccountNames: $targetAccountNames, maxAmount: $maxAmount)';
}
}
class TransferMoneyOptionFinder
implements OptionFinder<TransferMoneyOption, BankState> {
@override
Iterable<TransferMoneyOption> find(BankState state) sync* {
for (final MapEntry(key: sourceAccountName, value: balance)
in state.balanceByAccountName.entries) {
final targetAccountNames = state.balanceByAccountName.keys
.where((accountName) => accountName != sourceAccountName)
.toList();
if (targetAccountNames.isEmpty) continue;
yield TransferMoneyOption(
sourceAccountName: sourceAccountName,
targetAccountNames: targetAccountNames,
maxAmount: balance,
);
}
}
}
void main() {
final aggregate =
AggregateFullyGeneric<BankCommand, BankEvent, BankState, BankOption>(
initialState: BankState(balanceByAccountName: {}),
optionFinder: ComposableOptionFinder({
OpenAccountOption: OpenAccountOptionFinder(),
CloseAccountOption: CloseAccountOptionFinder(),
TransferMoneyOption: TransferMoneyOptionFinder(),
}),
commandDecider: ComposableCommandDecider({
OpenAccountCommand: OpenAccountCommandDecider(),
CloseAccountCommand: CloseAccountCommandDecider(),
TransferMoneyCommand: TransferMoneyCommandDecider(),
}),
eventReducer: ComposableEventReducer({
BalanceSetEvent: BalanceSetEventReducer(),
BalanceUnsetEvent: BalanceUnsetEventReducer(),
}),
eventStorage: InMemoryEventStorage([
BalanceSetEvent(accountName: 'Foo', balance: 1000),
]),
replayStoredEvents: true,
onEventReduced: (event, _, _) => print('Reduced $event'),
);
print('Initial state: ${aggregate.currentState}');
final commands = [
OpenAccountCommand(accountName: 'Faa', initialBalance: -500),
OpenAccountCommand(accountName: 'Bar', initialBalance: 500),
TransferMoneyCommand(
sourceAccountName: 'Bar',
targetAccountName: 'Foo',
amount: 100,
),
TransferMoneyCommand(
sourceAccountName: 'Bar',
targetAccountName: 'Foo',
amount: 500,
),
TransferMoneyCommand(
sourceAccountName: 'Foo',
targetAccountName: 'Bar',
amount: 500,
),
CloseAccountCommand(accountName: 'Foo'),
];
for (final command in commands) {
final options = aggregate.findOptions();
print('Options: $options');
print('Trying to process command: $command');
try {
final state = aggregate.process(command);
print('Updated state: $state');
} catch (e) {
print('Validation failed with $e');
}
}
final options = aggregate.findOptions();
print('Final options: $options');
}