Custom Ink is an eCommerce company, so our business involves creating orders for customers. And creating orders means allowing customers to check out, to bring their desired goods and services to the register and pay for them.
There are different ways people order from us - for example, in bulk, for a group, as part of a fundraiser - so we offer a number of different ways to create orders. Each of these ways has its own application, a domain service that bundles together the various entities and workflows needed to create orders. While each way of ordering has things that are unique about it, they also share a fair bit of functionality that is mostly or entirely the same, like checking out. This means duplicated code, divergent customer experiences, and our focus today, separate-but-identical integrations with a third-party tax service.
We set about to create a single checkout experience that could serve all our ways of ordering and, among other benefits, reduce our surface area with the tax service to a single client-consumer relationship. Our new architecture would look like this:
We did the work, and after a few months, we rolled out the first integration with our new checkout experience. But we found that instead of reducing our interface with our tax service, it expanded it! We failed to realize that each application still needed to invoice tax post-checkout, meaning it still talked to the tax service. Our architecture now looked like this:
This is the opposite of what we intended! How did we miss this?
Our goal with creating a checkout platform was not just to DRY checkout code - including our tax integration - but to pivot from a domain service model to a task-oriented one. In a domain service model, each application groups together and is responsible for its own business capabilities, like checkout. In a task-oriented model, each application represents a single task, a general business capability that can be used wherever needed. Eventually, tasks can be strung together to compose entire domains.
Despite this difference between the focus of services and tasks, their infrastructural shape is the same. They’re both applications deployed to your tech and infrastructure stack. They probably have a lot of the same configurations, and may even be built using the same framework and libraries.
This same-shapedness leads us to a familiar problem, similar to one further down the tech stack. In POODR, Sandi Metz tells us:
It’s tempting to think of object-orient applications as being the sum of their classes. Classes are so very visible… Classes are what you see in your text editor and what you check in to your source code repository.
[B]ut an object-oriented application is more than just classes. It is made up of classes but defined by messages. Classes control what’s in your source code respository; messages reflect the living, animated application.
Applications are our architectural objects. They are concrete, correspond to a code repository or module, and are what you deploy to your infrastructure. Tasks are akin to architectural messages. They are conceptual, representing processes or interactions that don’t automatically correspond to a grouping of code or deployment.
As with objects and messages, there is a tension between applications - what we build - and tasks - how it behaves. Like the hidden image in a Magic Eye puzzle, seeing tasks require concerted focus, even though they are the true subject of our software. It’s much easier to take in the codebase as it first appears to us. The domain services approach is, in a way, a concession to this tendency. It says “build the application, and the tasks will come.”
Programmers who have worked in a large, legacy OOP applications know the limits to this approach. They know that you end up with a Metzian “woven mat” of applications that coordinate in a chaotic, tangled fashion. Task-oriented architecture pushes back against this tendency and elevates the task to a first-order architectural principle - the single-responsibility principle for applications.
But deciding on a task-oriented architecture doesn’t mean you won’t lapse back into focusing on applications, like I did. I thought I would be DRYing up applications - we had domain services that spoke to a tax service. But that service-to-service collaboration performs multiple tasks, calculating tax for checkout is only one of them. Seeing tasks instead of applications is a skill. We practice at it and make mistakes.
Now that we’ve reminded ourselves to focus on the tasks, we can now revise our diagrams and get a better perspective on what we’ve accomplished.
Let’s revisit the first graph. We’ll correct it by adding in the missing task.
You may be thinking that things would have been clearer from the outset if we simply hadn’t forgotten about the “Invoice Tax” task. Yes and no - we would have known earlier that our tax service integration encompassed more than we thought, but it’s not automatic that we would have seen tasks. We could have looked at this and thought we needed a “tax integration service”.
Diagram #4 immediately makes the architectural accomplishment represented in diagram #3 much more obvious. We have, in fact, DRYed up our architecture! But what we DRYed was not the concrete tax service integration but a specific task that used it. We’ll simply repeat the diagram here.
Finally, we can now imagine the next step, the step that would in fact get us towards fully DRYing up our tax service integration - a new task component for tax invoicing:
That nicely expresses our commitment to focusing on tasks as the building blocks of our business systems. And one more benefit is now apparent - a concerted task-oriented architecture make it easier to keep seeing the tasks, both where we’ve already found them and where they still wait for discovery.