Designing Composable Web Interfaces
The key to designing workflow-compliant services is to offer a consistent set of actions and make it easy to share state data among services.
Below is an excerpt from my book "RESTful Web API Patterns and Practices Cookbook" from O'Reilly Media
A shared goal of many web service interface designers is the ability to easily connect multiple services to form a workflow. These are often called composable services. The key to designing workflow-compliant services is to offer a consistent set of actions and make it easy to share state data among services. These elements—shared actions and shared state—are at the heart of stable, composable service interfaces.
Composable services require shared interfaces, shared state, support for parallel processing, and a focus on keeping service actiions idempotent.
In addition to a shared set of actions and state data, workflow-compliant composable services support shared identifiers for the job (as correlation-id) and each task within the workflow job (as request-id). In a nutshell, composable services require shared interfaces, shared state, support for parallel processing, and a focus on keeping service actiions idempotent.
NOTE:
I'll talk about the importance of having a distinction between job and task as well as the need for parallel processing below.
Workflow Actions
In hypermedia-driven services, actions are expressed at input forms. Each form describes all that is needed to complete the action: the URL, the HTTP method, the supported media types, and the complete set of inputs.
While actions can be very specific (e.g., onboardCustomer, computeSalesTax, etc.), they can also be generic (writeRecord, filterData, etc.), but they must be completely described with all the inputs and related HTTP metadata included.
Most importantly, every task in a job must support parallel execution. That means, if there are four tasks in a job, the tasks can all run concurrently and may complete in any order. Jobs are run in sequence. If you have tasks that are non-parallel, you need to place them in separate jobs and run those jobs in the proper sequence.
NOTE:
Designing tasks that run in parallel may not be easy but running them at scale is. Conversely, designing tasks that run in sequence can be easy but running them at scale is challenging. It is better to pay for complications "up-front" at design-time than deal with complex error/race conditions at run-time.
The actions composable services need to support are:
Execute : The actual work (task) to be done (e.g., applySalesTax).
Repeat : Repeat the task again in an idempotent manner (e.g., applySalesTax can be safely repeated and still be the same expected results).
Revert : The ability to undo a completed task (e.g., revertSalesTax).
Continue : The ability to stop a workflow and then, after some pause, continue where you left off to complete the work.
Rerun : The ability to start the job from the beginning and rerun all the steps even if the workflow has been run before.
Cancel : The ability to cancel a running job. This might be due to an error in processing, missing state data, a time-out condition, etc.
NOTE:
It is a good idea for all workflow-compliant services to support all these actions, even if the action doesn’t “do anything” (e.g., no support for Revert). That will make supporting them easier over time.
All these actions should have an associated URL and an associated FORM. The first three actions apply to each step (referred to here as a task) within a workflow. The last three items apply to the complete set of tasks (called a job).
Shared State
Two other actions that are needed for workflow-compliant service interfaces are:
ReadState : The ability to load up related state properties for use by each task in the job.
WriteState : The ability to store the related state properties for use by other tasks within the same job.
Both of these actions need an associated URL that points to a shared HTTP resource. This resource URL should be passed to each task in the workflow. It is up to each service to know how to recognize state values in the state resource and, when needed, update existing properties or add new properties to the state resource. Often, the properties in this resource are used to fill in the FORMS exposed by the composable service’s actions.
Composing Workflows
Once you have a collection of reachable services that support the above interface (Execute, Repeat, Revert, Continue, Rerun, Cancel, ReadState, and WriteState) you can safely compose them into any number of valid workflows.
Consider the following set of available composable services:
Customers (read, create, update, remove)
Contacts (read, create, update, remove, assignToCustomer)
SalesReps (read, create, update, remove, assignToCustomer)
Here are just a few examples of the workflows we can now safely support:
Create a new Contact for an existing Customer
Contacts.Create + Contacts.assignToCustomer
Create a new Customer and assign an existing SalesRep
Customer.Create + SalesRep.assignToCustomer
Create a new Customer, a new associated Contact, and assign them to a SaleRep
Customer.Create + SalesRep.assignToCustomer
Contacts.Create + Contacts.assignToCustomer
NOTE:
If you use client-supplied unique IDs (.e.g. GUIDs) and make all actions idempotent, you can execute all these tasks within a single job; that is all actions can run parallel.
Of course, the shared state for these services needs to be managed, too. That means the state document needs to hold all the data needed in order to create and/or modify Customer, Contact, and SalesRep documents. The good news is that the same state document can be used for any workflow involving these three services. Even better, adding a new service means you just need to expand the existing state document.
But Wait, There's More!
As you might already notice, there are quite a few details left out of this high-level discussion. The work of error-handling, the process of mapping the public interface to the actions of each service, the details of "knowing" when a job is complete, etc. These all deserve attention in another post.
And -- even more importantly -- this kind of modeling of service composability leads, in my experience, to re-thinking just what a service is and what actions you expose. This has changed the way I design and implement services which, by the way, turns out to be very much along the unix dictum of "Make each program do one thing well." More on this in a future post, too.
The big takeaway from this? Composable services require shared interfaces, shared state, support for parallel processing, and a focus on keeping service actiions idempotent.