Composable procedural Workflows for Domain Modelling in TypeScript

by Giovanni Chiodi

6 min read

I was reading the Effect documentation, there's quite a buzz about it right now. I totally see where they're coming from and where they're going, BUT. For one thing, I'm not sure they add any original or important abstraction to functional programming, compared to, say, using fp-ts. Actually, adding custom abstractions is distracting from the original theoretical concepts of functional programming; if we start calling things with other names that's like muddling the pure waters of algebra.

And second, the problems with functional programming in TypeScript begins when we try to pipe functions, and ends when we stop trying. It's because of piping that we need monads and functors, and any framework that attempts it cannot avoid adopting the full package of functional programming theory.

Node.js developers struggle with piping. They're mostly used to procedural styles. When a few years ago RxJs was rampant in Angular, rock-star developers like myself could do amazing things with functional reactive pipes, that nobody else in the team could understand. Who can maintain that beautiful code now?

And another point: code does not exists in outer space. It exists here on Earth to solve somebody's problems (a.k.a. a Customer), that pays somebody else to build it. Whatever abstraction we build in the code must align with the broader context of software delivery. Because we attempt to build better architectures, we'd like to be domain-centric (onion, clean, hexagonal), maybe event-driven, but surely modular and domain-driven in the approach to software delivery. This means that we want to be able to connect the dots between problem space and solution space, from discovery, to business specifications, to tests, to observability. We need to have a system that facilitates those connections. And that's why I'll talk about Workflows today, a concept that unlike Effect does link to the broader context of domain-driven software delivery (See Domain Modelling Made Functional).

The main inspiration I took from the Effect docs is trying to generalise a Workflow signature. And the overall goal is create Workflows that are both composable and procedural, rather than purely functional. Instead of piping functions, we'll provide them as Steps, together with their Dependencies, but we'll handle them in a procedural way within the Workflow.

Composable Procedural Workflows

A Workflow is the implementation of a single use case of your application, executed within the domain layer. As such, it always takes a Command as input and returns one or more Events. In case of exceptions, it will return either a DomainError (one or more), or an UnknownError (like I/O errors). We can generalise this behaviour with the following signature:

type Workflow<Command, Event, DomainErrors> = 
			  (cmd: Command) => 
				  Event | Event[] | DomainErrors | UnknownError

In the previous post we created the Result<S, F> and AsyncResult<S, F> types that enable us to handle exceptions. We can use these utility types to rewrite the Workflow signature:

type Workflow<Command, Event, DomainErrors> = 
			  (cmd: Command) => 
				  WfResult<WfSuccess<Event>, DomainErrors>

type WfResult<S, F> = AsyncResult<S, F> | Result<S, F>
type WfSuccess<Event> = Event | Event[]

Just to clarify on the Command and Event types, these represent standard messages that we exchange through REST or async APIs - where Commands represent the intention of fulfilling a use-case, and Events represent something that has irrevocably happened in the system, like a state change:

type Message<T> = Command<T> | Event<T>

type Command<T> = {
	_d: "command"
	envelope: MsgEnvelope
	payload: T
}

type DomainEvent<T> = {
	_d: "event"
	envelope: MsgEnvelope
	payload: T
}

//type Envelope contains standard messaging information, that could be standardised using CloudEvents
//the payload is the actual data schema that the message contains
//examples:
type SubmitOrderPayload = {
	order: Order
}
type SubmitOrderCmd = Command<SubmitOrderPayload>

type OrderSubmittedPayload = {
	order: Order
}

type OrderSubmittedEvent = Event<OrderSubmittedPayload>

DomainErrors, instead, are custom types that depends on the failure paths of the use case, example:

type SubmitOrderFailures = "invalid_order" | "items_unavailable"

So the signature for an hypothetical SubmitOrderWorkflow, would look like:

type SubmitOrderWorkflow = 
	Workflow<SubmitOrderCmd, OrderSubmittedEvent, SubmitOrderFailures>
	
type Workflow<Cmd, Evt, DomainErrors> = 
			  (cmd: Cmd) => 
				  WfResult<WfSuccess<Evt>>, DomainErrors>

Ok. We managed to generalise the concept of Workflow in a way that is easy to understand, and perfectly aligned with a domain-driven software delivery approach. We could do our EventStorming sessions, fill up our business specifications, and for each use case create the corresponding Command, Events and eventual DomainErrors, and then chain them predictively through a Workflow.

We now need to address the composability part and the dependency injection part: our Workflow needs to be composed of smaller reusable steps, and needs to rely on external dependencies, injected to it mostly by the application layer.

Put it simply, we just need to add two other generic types to the signature, one for the steps and one for the dependencies:

type Workflow<Steps, Deps, Cmd, Evt, DomainErrors> = 
			(steps: Steps) =>
				(deps: Deps) =>
				  (cmd: Cmd) => 
					  WfResult<WfSuccess<Evt>, DomainErrors>

type WfResult<S, F> = AsyncResult<S, F> | Result<S, F>
type WfSuccess<E> = E | E[]
//smaller functions that compose the workflow
type Steps = unknown
//dependencies such as entities repositories
type Deps = unknown

Steps are just smaller functions that compose the workflow, for example the SubmitOrderWorkflow might need the steps:

  • ParseOrder: to parse the shape of the data received and in case reject it
  • ValidateProductCodes: maybe there are some errors in the product codes; for this we need to connect to the Products Service API
  • CheckAvailability: before submitting the order we need to verify that the items are in stock, so we'll call the Inventory Service to check the availability and in case allocate it
  • SaveOrder: if we didn't bump in to any failure so far then we're good to save the order in the DB, using the Orders Repository

All these steps require some external dependency, precisely:

  • A ProductsServiceClient: to connect to the Products Service API
  • An InventoryServiceClient: to connect to the Inventory Service API
  • An OrdersRepository: to save the order in the DB

We can now be precise about the SubmitOrderWorkflow signature:

type SubmitOrderWorkflow<
	SubmitOrderSteps, 
	SubmitOrderDeps, 
	SubmitOrderCommand, 
	OrderSubmittedEvent, 
	SubmitOrderFailures
	> = 
			(steps: SubmitOrderSteps) =>
				(deps: SubmitOrderDeps) =>
				  (cmd: SubmitOrderCommand) => 
					  WfResult<
						  WfSuccess<OrderSubmittedEvent>,
						  SubmitOrderFailures
						  >

type SubmitOrderSteps = {
	parseOrderFn: ParseOrder,
	validateProductCodesFn: ValidateProductsCode,
	checkAvaliabilityFn: CheckAvailability,
	saveOrderFn: SaveOrder
}

type SubmitOrderDeps = {
	ordersRepo: OrdersRepository,
	productsServiceClient: ProductsServiceClient,
	inventoryServiceClient: InventoryServiceClient
}

The great thing about domain modelling is that we use dependency inversion: if need to use anything in the model implementation, we'll define the structure of it, and the app and infra layer will import it and use it to instantiate any dependency. For example, in the domain layer we define an OrdersRepository type, and the infra layer will import it and implement it with a SequelizeOrdersRepository using the Sequelize ORM. The application layer will pass it to our domain layer as a dependency.

Dependency injection makes it possible for the domain layer to implement any logic, including those that rely on dependencies, without having those dependencies yet. Therefore, we can assume safely that we have all the types needed internally in the domain layer and that we can write any implementation of our workflows.

If we have particular implementations of the Steps, we can bake those in to the workflow with partial application:

const parseOrderFn: ParseOrder = //implementation
const validateProductCodesFn: ValidateProductsCode = //implementation
const checkAvaliabilityFn: CheckAvailability = //implementation
const saveOrderFn: SaveOrder = //implementation

const steps: SubmitOrderWorkflowSteps = {
	parseOrderFn,
	validateProductCodesFn,
	checkAvaliabilityFn,
	saveOrderFn
}

export const submitOrderWfWithSteps = submitOrderWorkflow(steps)

We can export this function so that the application layer can launch its execution providing only the infra dependencies and the SubmitOrderCommand from the user input:

//somewhere in the in the app layer...
//very approximate, there's an API somewhere...
//just pointing out the imports and the use of the workflow
import 
	submitOrderWfWithSteps, 
	submitOrderCmdFrom,
	{ SubmitOrderCommand },
	{ SubmitOrderDeps }
    from './domain/api'
    
import 
	{ SequelizeOrdersRepo },
	{ InventoryClient },
	{ ProductsClient }
    from './infra'

const cmd: SubmitOrderCommand = submitOrderCmdFrom(//some user input)
const deps: SubmitOrderDeps = {
	ordersRepo: SequelizeOrdersRepo,
	productsServiceClient: ProductsClient,
	inventoryServiceClient: InventoryClient
}
    
const res = await submitOrderWfWithSteps(deps)(cmd)

//because we use the AsyncResult utility type we can then
//handle the results in a deterministic way
switch(res._d){
    case("success"):
        handleSuccess(dataFrom(res))//this will involve sending the Events on the e.g. RabbitMq
    case("failure"):
        handleFailure(res as Failure)//here we probably translate the DomainError and respond to the user
    case("unknownError"):
        handleUnknownError(res as UnknownError)//here we probably translate/map the code of the Error and respond to the user
}

This would obviously happen in a more structured fashion, i.e. with a command handler.

Going back to a generalised signature for a generic Workflow, we have seen that Steps and Deps are just Record types:

type Steps {
   [key: string]: function;
}

type Deps {
   [key: string]: any;
}

//...these needs more thought but my brain has had it for today :)

So a general Workflow signature would be:

type Workflow<Steps, Deps, Cmd, Evt, DomainErrors> = 
			(steps: Steps) =>
				(deps: Deps) =>
				  (cmd: Cmd) => 
					  WfResult<WfSuccess<Evt>, DomainErrors>

type WfResult<S, F> = AsyncResult<S, F> | Result<S, F>
type WfSuccess<E> = E | E[]
//smaller functions that compose the workflow
type Steps {
   [key: string]: function;
}
//dependencies such as entities repositories
type Deps {
   [key: string]: any;
}

This was a tough one. I see potential and it works on my code. Together with the Error Handling utilities I'll soon add it to GitHub. To continue: at some point we should consider the Policy abstraction (from EventStorming) that is like the opposite of a Workflow, it takes an Event and determines what Command to execute. But before that the most important aspect to figure out is how to do Parsing. It's tricky, because TypeScript does not support runtime type validation, so it seems unavoidable to use some library like Zod. The drawback is that it forces us to rely more on schemas rather than types, and might weaken our pure TypeScript modelling flow, that is a real beauty and shall be preserved as much as possible. More next time.