Tjeerd in 't Veen
— 5 min read
When working with UI, it can sometimes be ambiguous where to place business logic. By imagining your features as a Command Line Tool, this can become quite clear.
Recently, we covered how to make features self-sufficient. After reading this article, you’ll be better equipped to make features portable, too. This way, your code can more easily support various frameworks or even platforms.
On paper, it might sound obvious to decouple business logic from views.
However, during regular day-to-day work, it can become blurry again where to put specific code. We may take shortcuts because of time constraints, or it might not be clear which part of UI should consist out of simple views. Conversely, it may get hard to figure out which views should know about business logic and contain data bindings.
All of this could cause business logic mixed in with UI. You may end with UI components that are only usable for one specific use-case. You could also end up with views that solely work with your framework.
To remedy this, when creating features, it can be a good idea to regularly check in with yourself and ask:
“What if this feature should also work as a Command Line Tool?”
Thinking of a feature as a Command Line Tool does not mean that you have to run your program on a server or without UI (a headless client). But, it helps us reason where to put logic, and defer UI decisions if we so please.
It also does not mean you need to make Command Line Tools to deliver a feature. Thinking of a feature as a Command Line Tool helps ensure to keep business logic decoupled. It means that all functionality and state is available, even without UI.
Let’s assume we are building an app to help us schedule family activities, such as outings, school-events, and chores.
Now let’s say, we are building the most “exciting” feature: The chore feature, which displays a list of chores for each family member to complete.
While implementing the UI, it becomes very tempting to place the chore logic near the UI that you’d be building.
You may implement this toggle logic in a viewmodel or declarative view, because that’s where the UI interaction occurs and where visually the state would change.
For instance, we may have a
Chore data model in a business layer in the
Chore domain. Then, in the
UI layer we have a
ChoreViewModel, which can toggle TODO items (the chores to complete).
But, once you pretend the feature should also work on the command line, you realize that this is a not the best choice. Because the toggling logic would only be available in the UI layer.
Instead, once we pretend this feature is a command line tool, it becomes apparent that the toggling should be out of the UI layer.
Let’s design one to see how that would look (we don’t have to implement it).
First, we fetch the list of chores for a specific family-member using a user ID.
Then, we use that ID to toggle its state to “completed”.
All ID’s are in the UUID format in this example.
% chores --fetch-chores-for-person ED8F92E2-983C-4BCD-864C-E77257238C62
5885206E-1836-45B3-9E5F-93A60176852B, "Take out the trash"
ACB77219-DA08-4A1B-ACE9-E0F9BAE9B515, "Clean up the cat's litterbox, meow!"
63E2D8FE-0B1D-42FF-980B-706611948F58, "Mow the lawn"
B5EE3633-23B5-4E19-9271-78E21702C621, "Prepare nachos for the dinner party"
% chores --toggle-todo ACB77219-DA08-4A1B-ACE9-E0F9BAE9B515
completed "Clean up the cat's litterbox, meow!"
This little exercise tells us that the viewmodel’s toggle logic should live in the
Chore domain instead.
By pretending you only have a Command Line Tool, you don’t have to wonder whether specific business logic should live in a viewmodel or declarative view, because by doing so the Command Line Tool would miss a feature.
We could not achieve this if any of this code lives in a view, or viewmodel. We can only make this feature work by making sure one of the business domains handles the state.
After moving the logic to the business layer, other clients can also use this same logic. For instance, a Headless client will now be able to toggle todo items, too.
A benefit of storing the state in the business domain –
Chore in this case – ensures it becomes a single source of truth. Now it can serve any UI framework, architecture, and target, such as phones, tablets, or watches. It means we make the model domain self-sustained, allowing it to be moved around (e.g. to a different repo) or you can even open source it.
By supporting an imaginary Command Line Tool, we already set up our codebase for success.
Although pretending to deliver a command tool is an excellent exercise, it can be worth it to make one for real; It can help to fire off a large amount of network calls to debug and (integration) test your feature. Then, you don’t have to rely on a simulator or UI Tests to test the network-integration.
Chore example is small. But, the thought exercise helps when it’s not clear where to place business logic.
By pretending we are also supporting a Command Line Tool, we completely disconnect the business logic. By disconnecting the business logic from UI, we stay more flexible.
If we were building for iOS, then whether our application runs on UIKit or SwiftUI becomes less important. Our application is self-sufficient and it “just so happens” that we can use it for UIKit, SwiftUI, AppKit, Apple TV, Apple Watch, Apple Vision Pro, widgets, App Clips, headless clients, or Command Line Tools, if we so wish.
If we want to implement background-support – such as having our app sync state while backgrounded – it will, again, be much easier without UI dependencies.
That extensive list alone should show you that you’ll be more nimble that way for many future directions in your company.
By thinking of our feature as a Command Line Tool, we go one layer beyond the UI. We make sure the entire feature works standalone without UI, as opposed to finishing 80% of the feature in the business domain, and leaving 20% in the UI domain – such as the ability to complete a TODO item.
Now you may think this “making a Command Line Tool mindset” is premature optimization. But, in most cases we secretly get away with leaking some business logic into the UI domain, such as putting business logic in viewmodels. This harms our flexibility in the future.
It’s still worth it to think of your app or feature as its own system without UI. Because even if you’re a mobile developer supporting only phones for all eternity, you may still experience significant changes and migrations.
To give an example; Not too long ago, the iOS community went from single screen apps to multiple-viewcontrollers per screen. They went from a single app to an ecosystem of an app with extensions, widgets and App Clips. Even when only supporting phones, mobile engineers went from imperative to declarative UI, such as migrating from UIKit to SwiftUI. Or from XML layout to Jetpack Compose for Android engineers.
Some unfortunate souls even have to make their feature work with hybrid solutions.
Not to mention, some features may – at some point – need to work when the user backgrounds an app. Which, again, is easier when there is no
import UI statements sprinkled around core logic.
If we were to leave some business logic lingering in the UI layer, then any of these aforementioned scenarios would have been more painful to support; We’d either have to duplicate our code, or complicate things to keep various data in sync. Or we would need to move code to the business domain, anyway.
Reality changes even on a single platform, and we benefit from decoupling UI from business logic as much as possible. Which is why thinking “What if this feature is also a Command Line Tool?” can help you decide where to put the proper logic.
Want to learn more?
From the author of Swift in Depth
Buy the Mobile System Design Book.
Suited for mobile engineers of all mobile platforms.