Modelling data and state transitions in TypeScript, using Workflows and EventStorming

by Giovanni Chiodi

6 min read

One of the strengths of Workflows as an abstraction in domain modelling is their one-to-one mapping to collaborative modelling techniques like EventStorming. So that in principles we could model requirements collaboratively with our domain experts, and then map that model one-to-one with our code. If we understand this powerful connection, we can then also leverage Workflows to model data structures and state transitions in the context of Event Driven Architectures and EventStorming.

We defined Workflows as functions that take a Command message as input and return an Event message as output. Because an Event represents something that has happened in the system, it conveys a change of state. Generally, the Command either carries the initial state in the payload, or triggers its retrieval from a data source, and the Event carries in its payload the new state, or parts of it, to notify the rest of the system.

In EventStorming we represent state transitions with color coded sticky notes, as follows:

Image

But what exactly is changing state?? Of course, an Aggregate. I find as many this term problematic, and I want to avoid DDD jargon, let's say that we're changing state of a Persisted Data Model. For example, some Orders, that we represent in EventStorming with a yellow box:

Image

The SubmitOrderWorkflow is therefore responsible for the state transition of an Order from IncomingOrder state (that we derive from the SubmitOrderCmd) to SubmittedOrderState (that we then broadcast in the OrderSubmittedEvt). Using the utility types we built in previous posts, we can already write it down:

//we'll add Steps, Deps and DomainErrors later...
type SubmitOrderWorkflow = Workflow<SubmitOrderCmd, OrderSubmittedEvt>

type SubmitOrderCmd = Command<SubmitOrderPayload>
type SubmitOrderPayload = {
	order: IncomingOrder
}

type OrderSubmittedEvnt = Event<OrderSubmittedPayload>
type OrderSubmittedPayload = {
	order: SubmittedOrder
}

//we model Order as the union of its states
type Order = IncomingOrder | SubmittedOrder
type IncomingOrder = unknown
type SubmittedOrder = unknown

We're still missing Steps, Deps, and DomainErrors for the workflow, and we still don't know exactly what an Order looks like, except that it can exist in two forms: incoming and submitted. To know that, we can keep modelling with the client.

The DDD Crew flavour of EventStorming places reddish stickies between commands and events to represent Constrains. We tried that with the client, and that's the result:

Image

It turns out, we can't simply submit orders right away. The client said that only orders with a valid address and product codes can be submitted. In addition, we also need to check that the ordered items are in stock. We can use these constrains to break down the workflow in to smaller steps:

Image

We also added the step of persisting the state change to the DB (we assume that parsing the command is already taken care of by the command constructor function). Great! We can code the Steps of the workflow:

//we'll add Deps and DomainErrors later...
type SubmitOrderWorkflow = Workflow<
	SubmitOrderSteps,
	SubmitOrderCmd, 
	OrderSubmittedEvt
	>
	
	
type SubmitOrderSteps = {
	validateAddress: ValidateAddress,
	checkAvailability: CheckAvailability,
	validateProductCodes: ValidateProductCodes
}

Unfortunately, all the steps are outside the Orders Bounded Context. But because we already had a Big-picture EventStorming session with the customers, we have them all mapped out in a Bounded Context Map. We know that for each context we'll build one or more services, and a client to talk to them. We can then place these Dependencies in the picture:

Image

We also added the OrdersRepository as a dependency, because that will be provided to the domain model by other layers in the app. Perfect, we can add the dependencies to the workflow:

//we'll add DomainErrors later...
type SubmitOrderWorkflow = Workflow<
	SubmitOrderSteps,
	SubmitOrderDeps,
	SubmitOrderCmd, 
	OrderSubmittedEvt
	>
	
	
type SubmitOrderSteps = {
	validateAddress: ValidateAddress,
	checkAvailability: CheckAvailability,
	validateProductCodes: ValidateProductCodes
}

type SubmitOrderDeps = {
	customerServiceClient: CustomerServiceClient,
	inventoryServiceClient: InventoryServiceClient,
	productsServiceClient: ProductsServiceClient,
	ordersRepository: OrdersRepository
}

The little model we made on the board makes it easy to list out the possible failures:

Image

We can add the list of failures as DomainErrors:

type SubmitOrderWorkflow = Workflow<
	SubmitOrderSteps,
	SubmitOrderDeps,
	SubmitOrderCmd, 
	OrderSubmittedEvt,
	SubmitOrderErrors
	>
	
	
type SubmitOrderSteps = {
	validateAddress: ValidateAddress,
	checkAvailability: CheckAvailability,
	validateProductCodes: ValidateProductCodes
}

type SubmitOrderDeps = {
	customerServiceClient: CustomerServiceClient,
	inventoryServiceClient: InventoryServiceClient,
	productsServiceClient: ProductsServiceClient,
	ordersRepository: OrdersRepository
}

type SubmitOrdersErrors = 
	"invalid_address" | "items_unavailable" | "wrong_products_codes"

Very good! The EventStorming board served us very well so far, is there anything else we can extract from it? We're still haven't figured out the specific of the data models. Maybe diving deeper in to the steps, keeping track of state changes of smaller units of state (i.e. Order's properties), can help us:

Image

Yes! The same way SubmitOrderWorkflow transforms an IncomingOrder into a SubmittedOrder, the steps that compose it transform smaller part of the Order from a state to the other. With this new info we can try to fill the gaps in the Order data model:

type Order = IncomingOrder | SubmittedOrder

type IncomingOrder = {
	address: IncomingAddress,
	orderLines: IncomingOrderLine[]
}

//for the sake of making it different from the valid address,
//let's pretend we receive just a string that we then validate
//with the external service
type IncomingAddress = {
	addressString: AddressString
}

/**
* @minLength 20
* @maxLength 200
*/
type AddressString = string

type IncomingOrderLine = {
	productCode: IncomingCode,
	qty: Qty
}

/**
* @minLength 10
* @maxLength 20
*/
type incomingCode = string

/**
* @minimum 1
* @maximum 100
*/
type Qty = number

type SubmittedOrder = {
	address: ValidAddress,
	orderLines: AllocatedOrderLine[]
}

//let's pretend that the address validation service checks the provided
//string and if valid it stores it internally, and gives us back another
//string and an id for lookup, because addresses are outside this domain and for easy printing
//of labels
type ValidAddress = {
	addressId: AddressId,
	label: AddressLabel
}

/**
* uuid
*/
type AddressId = string

/**
* @minLength 20
* @maxLength 200
*/
type AddressLabel = string


//let's pretend the products service checks and formats the product code
//and it also looks up the product id;
//the inventory service after it checks the availability records an allocation
//of products in the inventory with our order, so to mange the stock
type AllocatedOrderLine = {
	productCode = ValidProductCode,
	productId: ProductId,
	qty: Qty,
	allocationId: AllocationId
}

/**
* @minLength 10
* @maxLength 20
*/
type ValidProductCode = string

/**
* uuid
*/
type ProductId = string

/**
* uuid
*/
type AllocationId = string

Quite lengthy but insightful. The SubmittedOrder has AllocatedOrderLines, also keeping track of the allocationId. In case we don't manage to save the order in the DB, or it gets cancelled before shipping, we can use the allocationId to put back the items in the available stock, sending it back to the Inventory Service. This means that our workflow, when it fails saving the order, needs to emit a SaveOrderFailed event containing the allocationId, so that the Inventory Service can free the stock. We can add that to the list of failures:

type SubmitOrdersErrors = 
	"invalid_address" | 
	"items_unavailable" | 
	"wrong_products_codes" |
	SaveOrderFailedEvt
	
type SaveOrderFailedEvt = Event<SaveOrderFailedPayload>
type SaveOrderFailedPayload = {
	allocations: AllocationId[],
	error: Error
}

Giving a shot to the implementation (remember that we use the AsyncResult utility type to handle results, check the error handling post):


const submitOrderWorkflow: SubmitOrderWorkflow = 
	  (steps: SubmitOrderSteps) =>
	  (deps: SubmitOrderDeps) =>
	  (cmd: SubmitOrderCmd) => {
	  
		try{
			//
			//validate address
			//
			const { address } = cmd.payload.order
			const customersServiceClient = steps.customerServiceClient
			//initialise step function with dependency
			const validateAddressFn = 
				steps.validateAddress(customersServiceClient)
			const validateAddressRes = await validateAddressFn(address)
			if(isFailure(validateAddressRes)){
				return fail("invalid_address")
			}
			if(isUnknownError(validateAddressRes)){
				return validateAddressRes as UnknwnError
			}
			
			const validAddress = dataFrom(validateAddressRes)

			//
			//validate product codes
			//
			const { orderLines } = cmd.payload.order
			const codes = extractCodes(orderLines)
			const productsServiceClient = deps.productsServiceClient
			//initialise step function with dependency
			const validateCodesFn = 
				steps.validateCodes(productsServiceClient)
			const validateCodesRes = await validateCodesFn(codes)
			//handle failure
			if(isFailure(validateCodesRes)){
				return fail("invalid_product_codes")
			}
			//handle errors
			if(isUnknownError(validateCodesRes)){
				return validateCodesRes as UnknwnError
			}
			//success!
			const orderLinesWithCodes = dataFrom(validateCodesRes)
			
			//
			//check availability
			//
			const inventoryServiceClient = deps.inventoryServiceClient
			//initialise step function with dependency
			const checkAvailabilityFn = 
				steps.checkAvailability(inventoryServiceClient)
			const checkAvailabilityRes = 
				await checkAvailabilityFn(orderLinesWithCodes)
			//handle failures
			if(isFailure(checkAvailabilityRes)){
				return fail("iitems_not_available")
			}
			//handle errors
			if(isUnknownError(checkAvailabilityRes)){
				return checkAvailabilityRes as UnknwnError
			}
			//success!
			const orderLinesWithAllocation = dataFrom(validateCodesRes)

			//
			//save order
			//
			const ordersRepository = deps.inventoryServiceClient
			//initialise step function with dependency
			const saveOrderFn = 
				steps.saveOrder(ordersRepository)

			const order = {
				address: validAddress,
				orderLines: orderLinesWithAllocation
			}
			
			const saveOrderRes = 
				await saveOrderFn(order)
				
			//handle errors emitting an event
			if(isUnknownError(saveOrderRes)){
				const allocations = extractAllocations(order.orderLines)
				const saveOrderFailedPayload = {
					allocations
				}
				const SaveOrderFailedEvnt = 
					saveOrderFailedEvntFrom(saveOrderFailedPayload)
				return failWithEvent(SaveOrderFailedEvnt)
			}	
			
			//success, return OrderSubmittedEvent
			const submittedOrder = dataFrom(saveOrderRes)
			const OrderSubmittedEvnt = 
					orderSubmittedEvntFrom({order: submittedOrder})
			return succeed(OrderSubmittedEvnt)
		}catch(e){
			return unknownError("submitOrderWorkflow", e)
		}

This methodology is still a proof of concept, but it's working for me at the moment. Let me know if you have any comments!! The main value to me is to be able to match business specifications with code. To validate further the approach I need to figure out parsing and serialisation, so to connect the layers and do some tests. I'm looking at TypeBox for this. More next time.

CREDITS: Inspired as always by Domain Modelling made functional although I hope Scott doesn't read this yet until it's refined...