replay 1.1.0 copy "replay: ^1.1.0" to clipboard
replay: ^1.1.0 copied to clipboard

Event Sourcing framework for Dart to build auditable apps with immutable events.

example/replay_example.dart

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');
}
0
likes
160
points
230
downloads

Publisher

unverified uploader

Weekly Downloads

Event Sourcing framework for Dart to build auditable apps with immutable events.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

More

Packages that depend on replay