Style Guide
Event handlers in the state
The section about Changing Behavior described how commands and events can be handled differently depending on the state. One can take that one step further and define the event handler inside the state classes.
The state can be seen as your domain object and it should contain the core business logic. Then it’s a matter of taste if event handlers and command handlers should be defined in the state or be kept outside of it.
Here we are using a bank account as the example domain. It has 3 state classes that are representing the lifecycle of the account; EmptyAccount
, OpenedAccount
, and ClosedAccount
.
- Scala
- Java
-
source
public class AccountEntity extends EventSourcedBehaviorWithEnforcedReplies< AccountEntity.Command, AccountEntity.Event, AccountEntity.Account> { public static final EntityTypeKey<Command> ENTITY_TYPE_KEY = EntityTypeKey.create(Command.class, "Account"); // Command interface Command extends CborSerializable {} public static class CreateAccount implements Command { public final ActorRef<StatusReply<Done>> replyTo; @JsonCreator public CreateAccount(ActorRef<StatusReply<Done>> replyTo) { this.replyTo = replyTo; } } public static class Deposit implements Command { public final BigDecimal amount; public final ActorRef<StatusReply<Done>> replyTo; public Deposit(BigDecimal amount, ActorRef<StatusReply<Done>> replyTo) { this.replyTo = replyTo; this.amount = amount; } } public static class Withdraw implements Command { public final BigDecimal amount; public final ActorRef<StatusReply<Done>> replyTo; public Withdraw(BigDecimal amount, ActorRef<StatusReply<Done>> replyTo) { this.amount = amount; this.replyTo = replyTo; } } public static class GetBalance implements Command { public final ActorRef<CurrentBalance> replyTo; @JsonCreator public GetBalance(ActorRef<CurrentBalance> replyTo) { this.replyTo = replyTo; } } public static class CloseAccount implements Command { public final ActorRef<StatusReply<Done>> replyTo; @JsonCreator public CloseAccount(ActorRef<StatusReply<Done>> replyTo) { this.replyTo = replyTo; } } // Reply public static class CurrentBalance implements CborSerializable { public final BigDecimal balance; @JsonCreator public CurrentBalance(BigDecimal balance) { this.balance = balance; } } // Event interface Event extends CborSerializable {} public enum AccountCreated implements Event { INSTANCE } public static class Deposited implements Event { public final BigDecimal amount; @JsonCreator Deposited(BigDecimal amount) { this.amount = amount; } } public static class Withdrawn implements Event { public final BigDecimal amount; @JsonCreator Withdrawn(BigDecimal amount) { this.amount = amount; } } public static class AccountClosed implements Event {} // State interface Account extends CborSerializable {} public static class EmptyAccount implements Account { OpenedAccount openedAccount() { return new OpenedAccount(BigDecimal.ZERO); } } public static class OpenedAccount implements Account { final BigDecimal balance; @JsonCreator public OpenedAccount(BigDecimal balance) { this.balance = balance; } OpenedAccount makeDeposit(BigDecimal amount) { return new OpenedAccount(balance.add(amount)); } boolean canWithdraw(BigDecimal amount) { return (balance.subtract(amount).compareTo(BigDecimal.ZERO) >= 0); } OpenedAccount makeWithdraw(BigDecimal amount) { if (!canWithdraw(amount)) throw new IllegalStateException("Account balance can't be negative"); return new OpenedAccount(balance.subtract(amount)); } ClosedAccount closedAccount() { return new ClosedAccount(); } } public static class ClosedAccount implements Account {} public static AccountEntity create(String accountNumber, PersistenceId persistenceId) { return new AccountEntity(accountNumber, persistenceId); } private final String accountNumber; private AccountEntity(String accountNumber, PersistenceId persistenceId) { super(persistenceId); this.accountNumber = accountNumber; } @Override public Account emptyState() { return new EmptyAccount(); } @Override public CommandHandlerWithReply<Command, Event, Account> commandHandler() { CommandHandlerWithReplyBuilder<Command, Event, Account> builder = newCommandHandlerWithReplyBuilder(); builder.forStateType(EmptyAccount.class).onCommand(CreateAccount.class, this::createAccount); builder .forStateType(OpenedAccount.class) .onCommand(Deposit.class, this::deposit) .onCommand(Withdraw.class, this::withdraw) .onCommand(GetBalance.class, this::getBalance) .onCommand(CloseAccount.class, this::closeAccount); builder .forStateType(ClosedAccount.class) .onAnyCommand(() -> Effect().unhandled().thenNoReply()); return builder.build(); } private ReplyEffect<Event, Account> createAccount(EmptyAccount account, CreateAccount command) { return Effect() .persist(AccountCreated.INSTANCE) .thenReply(command.replyTo, account2 -> StatusReply.ack()); } private ReplyEffect<Event, Account> deposit(OpenedAccount account, Deposit command) { return Effect() .persist(new Deposited(command.amount)) .thenReply(command.replyTo, account2 -> StatusReply.ack()); } private ReplyEffect<Event, Account> withdraw(OpenedAccount account, Withdraw command) { if (!account.canWithdraw(command.amount)) { return Effect() .reply( command.replyTo, StatusReply.error("not enough funds to withdraw " + command.amount)); } else { return Effect() .persist(new Withdrawn(command.amount)) .thenReply(command.replyTo, account2 -> StatusReply.ack()); } } private ReplyEffect<Event, Account> getBalance(OpenedAccount account, GetBalance command) { return Effect().reply(command.replyTo, new CurrentBalance(account.balance)); } private ReplyEffect<Event, Account> closeAccount(OpenedAccount account, CloseAccount command) { if (account.balance.equals(BigDecimal.ZERO)) { return Effect() .persist(new AccountClosed()) .thenReply(command.replyTo, account2 -> StatusReply.ack()); } else { return Effect() .reply(command.replyTo, StatusReply.error("balance must be zero for closing account")); } } @Override public EventHandler<Account, Event> eventHandler() { EventHandlerBuilder<Account, Event> builder = newEventHandlerBuilder(); builder .forStateType(EmptyAccount.class) .onEvent(AccountCreated.class, (account, created) -> account.openedAccount()); builder .forStateType(OpenedAccount.class) .onEvent(Deposited.class, (account, deposited) -> account.makeDeposit(deposited.amount)) .onEvent(Withdrawn.class, (account, withdrawn) -> account.makeWithdraw(withdrawn.amount)) .onEvent(AccountClosed.class, (account, closed) -> account.closedAccount()); return builder.build(); } }
Notice how the eventHandler
delegates to methods in the concrete Account
(state) classes; EmptyAccount
, OpenedAccount
, and ClosedAccount
.
Optional initial state
Sometimes it’s not desirable to use a separate state class for the empty initial state, but rather treat that as there is no state yet. null
can then be used as the emptyState
, but be aware of that the state
parameter will then be null
for the first commands and events until the first event has be persisted to create the non-null state. It’s possible to use Optional
instead of null
but that results in rather much boilerplate to unwrap the Optional
state parameter and therefore null
is probably preferred. The following example illustrates using null
as the emptyState
.
- Scala
- Java
-
source
public class AccountEntity extends EventSourcedBehaviorWithEnforcedReplies< AccountEntity.Command, AccountEntity.Event, AccountEntity.Account> { public static final EntityTypeKey<Command> ENTITY_TYPE_KEY = EntityTypeKey.create(Command.class, "Account"); // Command interface Command extends CborSerializable {} public static class CreateAccount implements Command { public final ActorRef<StatusReply<Done>> replyTo; @JsonCreator public CreateAccount(ActorRef<StatusReply<Done>> replyTo) { this.replyTo = replyTo; } } public static class Deposit implements Command { public final BigDecimal amount; public final ActorRef<StatusReply<Done>> replyTo; public Deposit(BigDecimal amount, ActorRef<StatusReply<Done>> replyTo) { this.replyTo = replyTo; this.amount = amount; } } public static class Withdraw implements Command { public final BigDecimal amount; public final ActorRef<StatusReply<Done>> replyTo; public Withdraw(BigDecimal amount, ActorRef<StatusReply<Done>> replyTo) { this.amount = amount; this.replyTo = replyTo; } } public static class GetBalance implements Command { public final ActorRef<CurrentBalance> replyTo; @JsonCreator public GetBalance(ActorRef<CurrentBalance> replyTo) { this.replyTo = replyTo; } } public static class CloseAccount implements Command { public final ActorRef<StatusReply<Done>> replyTo; @JsonCreator public CloseAccount(ActorRef<StatusReply<Done>> replyTo) { this.replyTo = replyTo; } } // Reply public static class CurrentBalance implements CborSerializable { public final BigDecimal balance; @JsonCreator public CurrentBalance(BigDecimal balance) { this.balance = balance; } } // Event interface Event extends CborSerializable {} public enum AccountCreated implements Event { INSTANCE } public static class Deposited implements Event { public final BigDecimal amount; @JsonCreator Deposited(BigDecimal amount) { this.amount = amount; } } public static class Withdrawn implements Event { public final BigDecimal amount; @JsonCreator Withdrawn(BigDecimal amount) { this.amount = amount; } } public static class AccountClosed implements Event {} // State interface Account extends CborSerializable {} public static class OpenedAccount implements Account { public final BigDecimal balance; public OpenedAccount() { this.balance = BigDecimal.ZERO; } @JsonCreator public OpenedAccount(BigDecimal balance) { this.balance = balance; } OpenedAccount makeDeposit(BigDecimal amount) { return new OpenedAccount(balance.add(amount)); } boolean canWithdraw(BigDecimal amount) { return (balance.subtract(amount).compareTo(BigDecimal.ZERO) >= 0); } OpenedAccount makeWithdraw(BigDecimal amount) { if (!canWithdraw(amount)) throw new IllegalStateException("Account balance can't be negative"); return new OpenedAccount(balance.subtract(amount)); } ClosedAccount closedAccount() { return new ClosedAccount(); } } public static class ClosedAccount implements Account {} public static AccountEntity create(String accountNumber, PersistenceId persistenceId) { return new AccountEntity(accountNumber, persistenceId); } private final String accountNumber; private AccountEntity(String accountNumber, PersistenceId persistenceId) { super(persistenceId); this.accountNumber = accountNumber; } @Override public Account emptyState() { return null; } @Override public CommandHandlerWithReply<Command, Event, Account> commandHandler() { CommandHandlerWithReplyBuilder<Command, Event, Account> builder = newCommandHandlerWithReplyBuilder(); builder.forNullState().onCommand(CreateAccount.class, this::createAccount); builder .forStateType(OpenedAccount.class) .onCommand(Deposit.class, this::deposit) .onCommand(Withdraw.class, this::withdraw) .onCommand(GetBalance.class, this::getBalance) .onCommand(CloseAccount.class, this::closeAccount); builder .forStateType(ClosedAccount.class) .onAnyCommand(() -> Effect().unhandled().thenNoReply()); return builder.build(); } private ReplyEffect<Event, Account> createAccount(CreateAccount command) { return Effect() .persist(AccountCreated.INSTANCE) .thenReply(command.replyTo, account2 -> StatusReply.ack()); } private ReplyEffect<Event, Account> deposit(OpenedAccount account, Deposit command) { return Effect() .persist(new Deposited(command.amount)) .thenReply(command.replyTo, account2 -> StatusReply.ack()); } private ReplyEffect<Event, Account> withdraw(OpenedAccount account, Withdraw command) { if (!account.canWithdraw(command.amount)) { return Effect() .reply( command.replyTo, StatusReply.error("not enough funds to withdraw " + command.amount)); } else { return Effect() .persist(new Withdrawn(command.amount)) .thenReply(command.replyTo, account2 -> StatusReply.ack()); } } private ReplyEffect<Event, Account> getBalance(OpenedAccount account, GetBalance command) { return Effect().reply(command.replyTo, new CurrentBalance(account.balance)); } private ReplyEffect<Event, Account> closeAccount(OpenedAccount account, CloseAccount command) { if (account.balance.equals(BigDecimal.ZERO)) { return Effect() .persist(new AccountClosed()) .thenReply(command.replyTo, account2 -> StatusReply.ack()); } else { return Effect() .reply(command.replyTo, StatusReply.error("balance must be zero for closing account")); } } @Override public EventHandler<Account, Event> eventHandler() { EventHandlerBuilder<Account, Event> builder = newEventHandlerBuilder(); builder.forNullState().onEvent(AccountCreated.class, () -> new OpenedAccount()); builder .forStateType(OpenedAccount.class) .onEvent(Deposited.class, (account, deposited) -> account.makeDeposit(deposited.amount)) .onEvent(Withdrawn.class, (account, withdrawn) -> account.makeWithdraw(withdrawn.amount)) .onEvent(AccountClosed.class, (account, closed) -> account.closedAccount()); return builder.build(); } }
Mutable state
The state can be mutable or immutable. When it is immutable the event handler returns a new instance of the state for each change.
When using mutable state it’s important to not send the full state instance as a message to another actor, e.g. as a reply to a command. Messages must be immutable to avoid concurrency problems.
The above examples are using immutable state classes and below is corresponding example with mutable state.
- Java
-
source
public class AccountEntity extends EventSourcedBehaviorWithEnforcedReplies< AccountEntity.Command, AccountEntity.Event, AccountEntity.Account> { public static final EntityTypeKey<Command> ENTITY_TYPE_KEY = EntityTypeKey.create(Command.class, "Account"); // Command interface Command extends CborSerializable {} public static class CreateAccount implements Command { public final ActorRef<StatusReply<Done>> replyTo; @JsonCreator public CreateAccount(ActorRef<StatusReply<Done>> replyTo) { this.replyTo = replyTo; } } public static class Deposit implements Command { public final BigDecimal amount; public final ActorRef<StatusReply<Done>> replyTo; public Deposit(BigDecimal amount, ActorRef<StatusReply<Done>> replyTo) { this.replyTo = replyTo; this.amount = amount; } } public static class Withdraw implements Command { public final BigDecimal amount; public final ActorRef<StatusReply<Done>> replyTo; public Withdraw(BigDecimal amount, ActorRef<StatusReply<Done>> replyTo) { this.amount = amount; this.replyTo = replyTo; } } public static class GetBalance implements Command { public final ActorRef<CurrentBalance> replyTo; @JsonCreator public GetBalance(ActorRef<CurrentBalance> replyTo) { this.replyTo = replyTo; } } public static class CloseAccount implements Command { public final ActorRef<StatusReply<Done>> replyTo; @JsonCreator public CloseAccount(ActorRef<StatusReply<Done>> replyTo) { this.replyTo = replyTo; } } // Reply public static class CurrentBalance implements CborSerializable { public final BigDecimal balance; @JsonCreator public CurrentBalance(BigDecimal balance) { this.balance = balance; } } // Event interface Event extends CborSerializable {} public enum AccountCreated implements Event { INSTANCE } public static class Deposited implements Event { public final BigDecimal amount; @JsonCreator Deposited(BigDecimal amount) { this.amount = amount; } } public static class Withdrawn implements Event { public final BigDecimal amount; @JsonCreator Withdrawn(BigDecimal amount) { this.amount = amount; } } public static class AccountClosed implements Event {} // State interface Account extends CborSerializable {} public static class EmptyAccount implements Account { OpenedAccount openedAccount() { return new OpenedAccount(); } } public static class OpenedAccount implements Account { private BigDecimal balance = BigDecimal.ZERO; public BigDecimal getBalance() { return balance; } void makeDeposit(BigDecimal amount) { balance = balance.add(amount); } boolean canWithdraw(BigDecimal amount) { return (balance.subtract(amount).compareTo(BigDecimal.ZERO) >= 0); } void makeWithdraw(BigDecimal amount) { if (!canWithdraw(amount)) throw new IllegalStateException("Account balance can't be negative"); balance = balance.subtract(amount); } ClosedAccount closedAccount() { return new ClosedAccount(); } } public static class ClosedAccount implements Account {} public static AccountEntity create(String accountNumber, PersistenceId persistenceId) { return new AccountEntity(accountNumber, persistenceId); } private final String accountNumber; private AccountEntity(String accountNumber, PersistenceId persistenceId) { super(persistenceId); this.accountNumber = accountNumber; } @Override public Account emptyState() { return new EmptyAccount(); } @Override public CommandHandlerWithReply<Command, Event, Account> commandHandler() { CommandHandlerWithReplyBuilder<Command, Event, Account> builder = newCommandHandlerWithReplyBuilder(); builder.forStateType(EmptyAccount.class).onCommand(CreateAccount.class, this::createAccount); builder .forStateType(OpenedAccount.class) .onCommand(Deposit.class, this::deposit) .onCommand(Withdraw.class, this::withdraw) .onCommand(GetBalance.class, this::getBalance) .onCommand(CloseAccount.class, this::closeAccount); builder .forStateType(ClosedAccount.class) .onAnyCommand(() -> Effect().unhandled().thenNoReply()); return builder.build(); } private ReplyEffect<Event, Account> createAccount(EmptyAccount account, CreateAccount command) { return Effect() .persist(AccountCreated.INSTANCE) .thenReply(command.replyTo, account2 -> StatusReply.ack()); } private ReplyEffect<Event, Account> deposit(OpenedAccount account, Deposit command) { return Effect() .persist(new Deposited(command.amount)) .thenReply(command.replyTo, account2 -> StatusReply.ack()); } private ReplyEffect<Event, Account> withdraw(OpenedAccount account, Withdraw command) { if (!account.canWithdraw(command.amount)) { return Effect() .reply( command.replyTo, StatusReply.error("not enough funds to withdraw " + command.amount)); } else { return Effect() .persist(new Withdrawn(command.amount)) .thenReply(command.replyTo, account2 -> StatusReply.ack()); } } private ReplyEffect<Event, Account> getBalance(OpenedAccount account, GetBalance command) { return Effect().reply(command.replyTo, new CurrentBalance(account.balance)); } private ReplyEffect<Event, Account> closeAccount(OpenedAccount account, CloseAccount command) { if (account.getBalance().equals(BigDecimal.ZERO)) { return Effect() .persist(new AccountClosed()) .thenReply(command.replyTo, account2 -> StatusReply.ack()); } else { return Effect() .reply(command.replyTo, StatusReply.error("balance must be zero for closing account")); } } @Override public EventHandler<Account, Event> eventHandler() { EventHandlerBuilder<Account, Event> builder = newEventHandlerBuilder(); builder .forStateType(EmptyAccount.class) .onEvent(AccountCreated.class, (account, event) -> account.openedAccount()); builder .forStateType(OpenedAccount.class) .onEvent( Deposited.class, (account, deposited) -> { account.makeDeposit(deposited.amount); return account; }) .onEvent( Withdrawn.class, (account, withdrawn) -> { account.makeWithdraw(withdrawn.amount); return account; }) .onEvent(AccountClosed.class, (account, closed) -> account.closedAccount()); return builder.build(); } }