Demystifying Event driven architecture
Separating the wheat from the chaff
These days distributed architecture is getting a lot of press, which is a good thing. If you didn't notice, its hiding under the guise of 'Micro Services'. So people are getting on board the idea that a system can be deployed across multiple endpoints, where every endpoint has different handlers deployed to it. Handlers communicate amongst one another by sending messages.
How would such a handler look, lets cut some naive code to paint a picture to the concept. Since handlers are all about messages, lets define our message first. A simple poco will do.
public class RegisterCustomer
{
public string Name {get;set;}
public string Email {get;set;}
public string Phone {get;set;}
}
Since we are handling messages, lets define a simple interface that reflects that responsibility
public interface IHandleMessages<TMessage>
{
void Handle(TMessage message);
}
Now take a simple use case, register a customer, and send a welcome email. We will obviously have two handlers where one will need to call the other. So lets define a simple interface that abstracts away the responsibility of sending messages between handlers.
public interface IBus
{
void Send<TMessage>(TMessage message);
}
With that in place lets write our handlers
public class RegisterCustomerHandler :
IHandleMessages<RegisterCustomer>
{
IBus Bus {get;set;}
public void Handle(RegisterCustomer msg)
{
// todo: register a customer
Bus.Send(new WelcomeEmail
{
// not populating messages for brevity
});
}
}
public class WelcomeEmailHandler:IHandleMessages<WelcomeEmail>
{
public void Handle(WelcomeEmail msg)
{
// send the email
}
}
Technically the Send welcome email handler is under no obligation to not send an email when it receives a message for a customer that is not registered. That's easy to fix.
public class SendWelcomeEmail: IHandleMessages<WelcomeEmail>
{
public void Handle(WelcomeEmail msg)
{
if(Query(new CustomerByEmail{}) is CustomerNotFound) return;
if(Query(new RegistrationEmailByCustomer {}) is EmailNotFound)
// send email and save email record
}
}
Organically this approach will suffice for now. But we will see later as we introduce events how this solution evolves. Now lets consider a slightly more complex business scenario. Place an Order. Then send a order placed email, and reserve stock.
public class PlaceOrderHandler: IHandleMessages<CustomerOrder>
{
IBus Bus {get;set;}
public void Handle(CustomerOrder msg)
{
// todo: place the order
Bus.Send(new OrderPlacedEmail {});
Bus.Send(new ReserveStockRequest {});
}
}
public class SendOrderPlacedEmail: IHandleMessages<OrderPlacedEmail>
{
public void Handle(OrderPlacedEmail msg)
{
if(!Query(new OrderPlaced{}) return;
// todo: send the email
}
}
public class ReserveStockHandler: IHandleMessages<ReserveStockRequest>
{
public void Handle(ReserveStockRequest msg)
{
if(!Query(new OrderPlaced{}) return;
// todo: reserve stock
}
}
We followed the same pattern as we did with Register customer and Send welcome email. But now notice both the handlers above have to ensure the same thing, an Order is placed before they go about their business. Note also the Place Order handler knows a bit too much of what needs to happen after an Order is placed. For e.g if the business later decides when a customer places an Order we give them some frequent flyer points, then you have to go to the placed order handler, and insert the Bus.Send statement for adding frequent flyer points. Okay so this probably violates the Open Closed principle, and the Single Responsibility principle as the Place Order handler has too many responsibilities.
Lets introduce events into the mix. What about we publish an event after we place an Order. For that we have to extend our IBus interface to reflect this new responsibility.
public interface IBus
{
void Send<TCommand>(TCommand cmd);
void Publish<TEvent>(TEvent @event);
}
Now our Place Order handler will slightly change.
public class PlaceOrderHandler: IHandleMessages<CustomerOrder>
{
IBus Bus {get;set;}
public void Handle(CustomerOrder command)
{
// todo: place the order
Bus.Publish(new OrderPlaced{});
}
}
This also means our OrderPlacedEmail and ReserveStockRequest commands disappear. Those handlers are now simply listening to OrderPlaced.
public class SendOrderPlacedEmail: IHandleMessages<OrderPlaced>
{
public void Handle(OrderPlaced @event)
{
// todo: send the email
}
}
public class ReserveStockHandler: IHandleMessages<OrderPlaced>
{
public void Handle(OrderPlaced @event)
{
// todo: reserve stock
}
}
Now if the business were to provide the rule to add frequent flyer points when an Order is placed, you simply write another handler that handles OrderPlaced and you are done.
Hopefully that explains the power of events, and when should you send commands vs publish events. Whenever I get into this fix, I always ask myself a simple question, should this handler be invoked only when another handler successfully complets, if true, then the former should publish an event, and the latter should subscribe to it, rather than the latter just handling a command.
Allow me to take this a step further, the refunds use case. You can only refund when a paid product is returned. Thinking events, we can identify there are two event that need to have happened here, and order must be paid, and the product is returned. For a refund to be initiated both must have happened.
public class RefundsHandler :
IHandleMessages<OrderPaid>,
IHandleMessages<ProductReturned>
{
public void Handle(OrderPaid @event)
{
// only when product returned ??
// todo: refund
}
public void Handle(ProductReturned @event)
{
// only when order paid ??
// todo: refund
}
}
How do we ensure the invariants are met? We almost need some local state to record we have received the event. The business meanwhile adds another curveball that refunds can only be handed out inside 30 days from payment. This is now a process of its own. A long running one that can span 30 days. A long running process is often coined as a Saga. So lets represent refunds as a Saga. And since we can only refund inside 30 days, we need to some how terminate this saga in 30 days. How about we trigger a timeout of some sorts when the Order is paid, that will in 30 days ensure the saga completes. A lot of complex code, but lets write some psuedo code first to represent these concepts.
public class Saga<TSaga>
{
public TSaga Data {get;set;}
public IBus Bus {get;set;}
// todo: implement methods
RequestTimeOut<TTimeout>(Timespan within) {}
MarkAsComplete() {}
}
public class Refund
{
public bool OrderPaid {get;set;}
public bool ProductReturned {get;set;}
}
public class CoolingOffPeriodOver
{}
public class RefundsSaga : Saga<Refund>,
IHandleMessages<OrderPaid>,
IHandleMessages<ProductReturned>,
IHandleTimeouts<CoolingOffPeriodOver>
{
public void Handle(OrderPaid @event)
{
Data.OrderPaid = true;
if(Data.ProductReturned)
{
// todo: refund
return;
}
RequestTimeout<CoolingOffPeriodOver>(within: 30.Days());
}
public void Handle(ProductReturned @event)
{
Data.ProductReturned = true;
if(Data.OrderPaid)
// todo: refund
}
public void Handle(CoolingOffPeriodOver timeout)
{
MarkAsComplete();
}
}
Now what does this Handler/Saga look like? Its got state of its own, its responding to events, its got a core responsibility to fulfill, i.e. refund. Looks like a domain object to me. It is created, goes through a set number of states, and comes to a final resting point. This for me has been the turning point with Event driven development. To understand how domain driven design ties into event driven development.
You might be thinking at this stage, all said and done, with all this pseudo code, there's tons and tons of infrastructure code to write to have such constructs that capture non trivial requirements with very little code, and have no infrastructure noise.
There is none to write, nada. I have had the joy of writing production apps with exactly the code I showed above for some seriously complex requirements. What framework do I use? NServiceBus.
So hopefully with this long post I have explained to you what event driven architecture looks like, what concepts are at play, how DDD ties into it, and NServiceBus helps bring all that together in a way that it delivers fantastic value yet stays completely out of your way.
Relishing the challenge of humbling myself by convincing others to avoid the mistakes I have made.