The quest for safe code reuse and minimal accidental complexity is a never-ending one. At Seasoned, while striving for that, we have developed a library called Domain Functions, and its impact has been overwhelmingly positive. It is gaining traction outside of our company, therefore I'd like to expose the library's rationale and give an overview of its benefits.
An abstraction for business logic
We often have various options to model a given problem and its solution, especially when the problem is within the lower layers of the software development realm. However, as I have noticed throughout my career, the main business logic sometimes seems to be an afterthought. After modeling the queues, database relations, controllers, loggers, and routers, the domain logic just emerges in the in-betweens.
In the scenario above, the essential logic required for the business gets entangled with different layers to carry data around and perform the computations. This entanglement increases accidental complexity, and it becomes harder to undo as the software changes. There are many famous approaches to deal with these issues, such as Hexagonal architecture, Domain-driven design, and Event-driven architecture. Still, at Seasoned, we missed a simple pattern that could be packaged as a library and would foster the decoupling of business logic with a low adoption barrier.
So we created Domain Functions, which lives in the space between a library and a design pattern since the library proves to be more useful when used with a simple convention: domain functions' names should mean something within the business logic context.
A quick example
Let's imagine a to-do list app where you can have multiple users. You would need a way to present the to-do items for a given user, such functionality could be called "getUserTodoItems". This name makes sense in the domain logic and does not mention any concrete storage, display, or network medium.
You can also talk about the inputs and outputs using words from the domain: "getUserTodoItems" needs an "Email" to identify the "User" and will return a "TodoItems" or it will fail when the user is not found.
Once you specify the necessary inputs, the domain function will automatically parse the run-time parameters ensuring they always fit their types, making it much easier to reason about the code. The outputs will conform to a "Result" type that encapsulates a case of success and all failure cases, which makes exceptions visible and allows us to model errors using types.
In pseudo-code, calling a domain function looks like this:
const result = getUserTodoItems(inputParameters)
if (result.success) {
let todoItems = result.data
…
} else {
// maybe inputParameters did not have an email property
// or it was ill-formatted.
// Here the domain function will provide
// detailed error information on the failure.
}
With great composition comes great power
Now let's assume that we have implemented three domain functions for our to-do list app:
getUserProfile
getUserTodoItems
markItemsAsDone
Having these building blocks, it should be easy to derive new functionality by combining them. To display the user data along with their to-do items, ideally, I should be able to express the data fetching in parallel as: "call getUserProfile and getUserTodoItems using the same input and combine the results". Our library achieves that by using the helper "all", which means "combine multiple domain functions in parallel".
Likewise, when I want to mark all user to-do items as done, I can compose a sequence of domain operations as: "call getUserTodoItems and apply markItemsAsDone on its result". The "pipe" combinator would accomplish this.
One exciting aspect of these compositions is that they result in yet another domain function, which could be further composed. This provides great abstraction power and allows the developer to think in terms of how to operate domain functions. In the example above, getUserProfile
could be implemented by fetching from a REST endpoint. At the same time, getUserTodoItems
could gather data using a SQL database. We could gleefully ignore those details when trying to get an operation that satisfies our requirements.
How do domain functions fit the code base?
The idea is to only deal with an abstraction such as Request
in a couple of functions that extract the input for my core domain functions. The same could be said about producing a Response
object to render. The code for any endpoint would look very similar to (in pseudo-code):
const inputs = extractInputsFrom(request)
const result = someDomainFunction(inputs)
const response = buildResponseFrom(result) // I’d love a reverse application operator here
return render(response)
This avoids muddling our business logic with concerns particular to a protocol or framework that handles our input and output, making our code simpler, safer, and easier to compose.
In case you migrate the project to another framework, all functions on the right side of the diagram above will remain intact.
For more information on how to use Domain Functions with Remix, you can refer to How domain-functions improves the (already awesome) DX of Remix projects? by @gugaguichard.