Skip to content

Commands or Events: Which One for Workflow?

So, you’re building a system around business processes and workflows. Great! But where does the code go that has to orchestrate all this?

This is a common question I’ve received, especially from members of my channel on our private Discord. Let’s dive into the guidelines on process managers, bounded contexts, and clever ways to collocate workflow steps.

YouTube

Check out my YouTube channel, where I post all kinds of content accompanying my posts, including this video showing everything in this post.

Understanding Workflows

Often, smaller workflows chain together to create larger workflows. Picture this: we have three different boundaries—sales, shipping, and billing. When an order is placed, sales publishes an “order placed” event.

Once the customer is charged, billing publishes an “order billed” event.

However, shipping can’t proceed until it knows that both events have occurred. Why? Because in asynchronous messaging, events may come in out of order!

We need to make sure both Order Placed and Order Billed events happened before shipping the order.

Code Example: Workflow in Action

Now, let’s move to our code example using C# with NServiceBus. Don’t worry if you aren’t in the .NET space; the principles will be clear regardless of the code’s appearance. In the sales boundary, we have a place order handler that publishes the “order placed” event.

public class PlaceOrderHandler(ILogger<PlaceOrderHandler> logger) :
IHandleMessages<PlaceOrder>
{
public Task Handle(PlaceOrder message, IMessageHandlerContext context)
{
logger.LogInformation("Received PlaceOrder, OrderId = {message.OrderId}", message.OrderId);
// This is normally where some business logic would occur
// Uncomment to test throwing a systemic exception
//throw new Exception("BOOM");
// Uncomment to test throwing a transient exception
//if (Random.Shared.Next(0, 5) == 0)
//{
// throw new Exception("Oops");
//}
var orderPlaced = new OrderPlaced
{
OrderId = message.OrderId
};
return context.Publish(orderPlaced);
}
}
view raw PlaceOrder.cs hosted with ❤ by GitHub

On the billing side, we have a similar setup that publishes the “order billed” event.

public class OrderPlacedHandler(ILogger<OrderPlacedHandler> logger) :
IHandleMessages<OrderPlaced>
{
public Task Handle(OrderPlaced message, IMessageHandlerContext context)
{
logger.LogInformation("Received OrderPlaced, OrderId = {OrderId} - Charging credit card...", message.OrderId);
var orderBilled = new OrderBilled
{
OrderId = message.OrderId
};
return context.Publish(orderBilled);
}
}

Here’s how it all connects: in the shipping boundary, we want to know that both events have occurred to ship the order. This is where our shipping policy comes into play. It states that if an order is placed and billed, we can ship the order. The order can come in any sequence; we’re just handling both events and capturing their state.

class ShippingPolicy(ILogger<ShippingPolicy> logger) : Saga<ShippingPolicyData>,
IAmStartedByMessages<OrderBilled>,
IAmStartedByMessages<OrderPlaced>
{
protected override void ConfigureHowToFindSaga(SagaPropertyMapper<ShippingPolicyData> mapper)
{
mapper.MapSaga(sagaData => sagaData.OrderId)
.ToMessage<OrderPlaced>(message => message.OrderId)
.ToMessage<OrderBilled>(message => message.OrderId);
}
public Task Handle(OrderPlaced message, IMessageHandlerContext context)
{
logger.LogInformation("OrderPlaced message received for {OrderId}.", message.OrderId);
Data.IsOrderPlaced = true;
return ProcessOrder(context);
}
public Task Handle(OrderBilled message, IMessageHandlerContext context)
{
logger.LogInformation("OrderBilled message received for {OrderId}.", message.OrderId);
Data.IsOrderBilled = true;
return ProcessOrder(context);
}
async Task ProcessOrder(IMessageHandlerContext context)
{
if (Data.IsOrderPlaced && Data.IsOrderBilled)
{
await context.SendLocal(new ShipOrder() { OrderId = Data.OrderId });
MarkAsComplete();
}
}
}

Guidelines for Commands and Events

So, where does the code live that deals with this workflow? It exists within the boundary that needs to take action. When communicating between boundaries, use events. For example, when the order is placed, that event is consumed by billing and shipping. When the order is billed, shipping consumes that event as well. We’re not telling shipping to do something; we’re just publishing events and letting the workflow be there.

Inside a boundary, you can use either commands or events. However, here’s a guideline: Generally, avoid crossing boundaries with commands.

If billing sends a command to shipping to ship the order, it creates a tighter coupling. With events, as the publisher, you have no idea who the consumers are. You’re decoupling the workflow to a degree, but you’re still tied to the event schema and the location where the event is published.

Decoupling Through Commands and Events

It’s about understanding the implications of using commands versus events. Commands invoke behavior, and the consumer must respond. Events simply define that something happened, allowing flexibility in how different boundaries react to it.

Complexity vs. Simplicity

You might think this adds unnecessary complexity, and perhaps in your context, a simple RPC call or function call in a monolith could suffice. However, in larger systems, managing coupling becomes natural because that’s how the business works and organizes itself. The examples are simplified to illustrate the flow without diving into complex domain intricacies.

Join CodeOpinon!
Developer-level members of my Patreon or YouTube channel get access to a private Discord server to chat with other developers about Software Architecture and Design and access to source code for any working demo application I post on my blog or YouTube. Check out my Patreon or YouTube Membership for more info.

Leave a Reply

Your email address will not be published. Required fields are marked *