Introduction
TL;DR: I managed to minimize the CQRS overhead even further.
Over the years, I have been trying to minimize CQRS in several iterations, even releasing a framework and a lib in doing so. Yet, I have still not been satisfied by the approach I reached. This time, I once again am pretty satisfied with the way things turned out, and they actually seem to require even way less overhead...
How It Works
The concept is quite simple: I use a generic message class to implement messaging. Next to this, I use the virtual
keyword:
- A class can contain virtual properties; these properties define the unique key of the instance. (e.g., an "
Account
" class has a "protected virtual string AccountId {get;set;}
"). - When invoking a message on a class type, an instance is loaded where the unique key is loaded based on the match between the message parameters and the classes' virtual properties. A message only gets invoked if it contains all the virtual properties from the class.
- In order to alter state, one should use a virtual method.
- One can only send messages targeting non-virtual methods to a class instance.
- Non-virtual methods should never alter state, but instead call a virtual method that alters the state...
- When rebuilding state based on past events, only the messages targeting the virtual methods are invoked to rebuild the state; no messages are emitted.
The advantage: all the wiring is convention-based; and once you get it, it is quite easy: altering state should only happen through virtual methods, but these virtual methods should never contain any logic and just alter state or do nothing. The intercepted call to the virtual method gets emitted and gets processed by any non-virtual methods in other classes (if the message matches the classes' key).
Maybe An Example Can Make It More Clear
public class Account
{
protected virtual string AccountId { get; set; }
bool IsRegistered = false;
private Decimal Balance = 0;
public void RegisterAccount(string OwnerName)
{
if (IsRegistered)
return;
OnAccountRegistered(OwnerName);
}
public void DepositAmount(decimal Amount)
{
if (Amount <= 0)
OnTransactionCancelled
("Deposit", "Your amount has to be positive");
OnAmountDeposited(Amount);
}
public void WithdrawAmount(decimal Amount)
{
if (Amount <= 0)
OnTransactionCancelled
("Withdraw", "Your amount has to be positive");
if (Amount > Balance)
OnTransactionCancelled("Withdraw",
"Your amount has to be smaller then the balance");
OnAmountWithdrawn(Amount);
}
public void Transfer(string TargetAccountId, decimal Amount)
{
if (Amount <= 0)
OnTransactionCancelled
("Transfer", "Your amount has to be positive");
if (Amount > Balance)
OnTransactionCancelled("Transfer",
"Your amount has to be smaller then the balance");
OnTransferProcessedOnSource(TargetAccountId, Amount);
}
public void ProcessTransferOnTarget(string SourceAccountId, decimal Amount)
{
if (IsRegistered)
OnTransferProcessedOnTarget(SourceAccountId, Amount);
else
OnTransferFailedOnTarget(SourceAccountId, Amount);
}
public void CancelTransferOnSource(string TargetAccountId, decimal Amount)
{
OnTransferCancelledOnSource(TargetAccountId, Amount);
}
protected virtual void OnAccountRegistered(string OwnerName)
{ IsRegistered = true; }
protected virtual void OnAmountDeposited(decimal Amount)
{ Balance += Amount; }
protected virtual void OnAmountWithdrawn(decimal Amount)
{ Balance -= Amount; }
protected virtual void OnTransferProcessedOnSource(
string TargetAccountId, decimal Amount) { Balance -= Amount; }
protected virtual void OnTransferCancelledOnSource(
string TargetAccountId, decimal Amount) { Balance += Amount; }
protected virtual void OnTransferProcessedOnTarget(
string SourceAccountId, decimal Amount) { Balance += Amount; }
protected virtual void OnTransferFailedOnTarget
(string SourceAccountId, decimal Amount) { }
protected virtual void OnTransactionCancelled(string what, string reason) { }
}
public class AccountTransferSaga
{
public void OnTransferProcessedOnSource
(string AccountId, string TargetAccountId, decimal Amount)
{
ProcessTransferOnTarget(TargetAccountId, AccountId, Amount);
}
public void OnTransferFailedOnTarget
(string AccountId, string SourceAccountId, decimal Amount)
{
CancelTransferOnSource(SourceAccountId, AccountId, Amount);
}
protected virtual void ProcessTransferOnTarget(string AccountId,
string SourceAccountId, decimal Amount) { }
protected virtual void CancelTransferOnSource(string AccountId,
string TargetAccountId, decimal Amount) { }
}
public class AccountBalances
{
public AccountBalances() { }
public Dictionary<string,
Decimal> Balances = new Dictionary<string, decimal>();
public virtual void OnAccountRegistered(string AccountId)
{ Balances.Add(AccountId, 0); }
public virtual void OnAmountDeposited(string AccountId, decimal Amount)
{ Balances[AccountId] += Amount; }
public virtual void OnAmountWithdrawn(string AccountId, decimal Amount)
{ Balances[AccountId] -= Amount; }
public virtual void OnTransferProcessedOnTarget(string AccountId,
string SourceAccountId, decimal Amount)
{
Balances[AccountId] += Amount;
Balances[SourceAccountId] -= Amount;
}
}
[TestMethod]
public void Deposits_and_withdraws_should_not_interfere_with_each_other()
{
var SUT = new YakShayBus();
SUT.RegisterType<Account>();
SUT.RegisterType<AccountTransferSaga>();
SUT.RegisterType<AccountBalances>();
var ms = new MessageStore();
SUT.HandleUntilAllConsumed(Message.FromAction(
x => x.RegisterAccount(AccountId: "account/1",
OwnerName: "Tom")), ms.Add, ms.Filter);
SUT.HandleUntilAllConsumed(Message.FromAction(
x => x.RegisterAccount(AccountId: "account/2",
OwnerName: "Ben")), ms.Add, ms.Filter);
SUT.HandleUntilAllConsumed(Message.FromAction(
x => x.DepositAmount(AccountId: "account/1",
Amount: 126m)), ms.Add, ms.Filter);
SUT.HandleUntilAllConsumed(Message.FromAction(
x => x.DepositAmount(AccountId: "account/2",
Amount: 10m)), ms.Add, ms.Filter);
SUT.HandleUntilAllConsumed(Message.FromAction(
x => x.Transfer(AccountId: "account/1",
TargetAccountId: "account/2", Amount: 26m)),
ms.Add, ms.Filter);
SUT.HandleUntilAllConsumed(Message.FromAction(
x => x.WithdrawAmount(AccountId: "account/2",
Amount: 10m)), ms.Add, ms.Filter);
var bal = new AccountBalances();
SUT.ApplyHistory(bal, ms.Filter);
bal.Balances.Count.ShouldBe(2);
bal.Balances["account/1"].ShouldBe(100m);
bal.Balances["account/2"].ShouldBe(26m);
}
As usual, the full source can be found over at github.
Conclusion
This is yet another evolutionary approach to CQRS, and it feels pretty neat (even though the same things applied to the previous versions. Let me know what you think!