ViewModels and Separation of Concern
June 02, 2020I see many developers adding too many code inside their ViewModel ending with a Massive ViewModel 🦛 while one of the first reason to use it was to actually avoid... Massive ViewController 🤷.
Why is it happening? I think it is coming from one simple reason: they forget to think about Separation of Concern.
Let's build a (simplified) ViewModel handling a book that can be refreshed and ordered.
class BookViewModel {
let book: BehaviorRelay<Book>
private let refreshAction: (Book) -> Future<Book>
private let addToBasketAction: (Book) -> Future<Bool>
func refresh() {
refreshAction().map { self.book = $0 }
}
func addToBasket() {
addToBasket(book.value)
}
}
So far so good. Now our view need to display book information right? So let's just add that:
class BookViewModel {
let book: BehaviorRelay<Book>
private(set) lazy var title = book.map(\.title)
private lazy var publishedAtFormatter: DateFormatter {
// build a date formatter
}
private(set) lazy var publishedAt = book.map(\.publishedAt).map(self.publishedAtFormatter.string(from:))
//... and so on
func refresh() { ... }
func addToBasket { ... }
}
And we'll now use those properties in our views. Great. But we just did something wrong: we broke our Separation of Concern.
Separation of Concern
In most apps you'll find at least those three layers 🍰:
- 📱Presentation, that's everything about UI
- 💼Business (or domain), all the stuff related to ruling your app
- 💾Data, technical layer to access data
Wether your app/product is using those three layers or others the rule is the same: to respect SoC (Separation of Concern) any class should lie into exactly one layer.
To which layer does our BookViewModel
belong to? Well according to the first version we did it should belong to Business layer as it is dealing with a Book
and some other related actions. But the second version actually added relationships with Presentation layer as well. Our ViewModel will now change whenever one of those two layers will have new requirements:
- Need to display more information to user? Or to change formatting? Our ViewModel will change
- Need to change some rules about how to added to basket ? Our ViewModel will change.
This has many drawbacks, the biggest ones being:
- We're getting a Massive ViewModel 🦛: we add code for two layers
- It's hard to test. We'll have to mock/stub a lot of things to test unrelated things. Here for example we'll have to mock
refreshAction
andaddToBasketAction
while testingpublishedAt
formatting. Yet the two are not related.
Now I must admit ViewModel naming is maybe not the smartest one in the world making its definition not clear: is it a model handling a view? Or its data? You'll have to choose between the two.
ViewState
In this pattern ViewModel is acting like in our first example: it is handling domain attributes and perform any necessary actions. It mean you should have very few properties inside it and all of them domain oriented. Every stuff related to Presentation will either lie into a ViewController or some dedicated classes. For instance here is how I usually bind ViewModel to the UI:
class BookViewController: UIViewController {
func bind() {
bookViewModel.book.map(\.title).bind(to: view.titleLabel.rx.text).disposed(by: bag)
bookViewModel.book.map(\.publishedAt).map(DateConverter.string).bind(to: view.publishedAt.rx.text).disposed(by: bag)
view.addToBasketButton.rx.tap.subscribe(onNext: { [viewModel] in viewModel.addToBasket() }).disposed(by: bag)
}
}
enum DateConverter {
static func string(_ date: Date, locale: Locale = .current) -> String {
// any needed formatting
}
}
With this SoC not only is my BookViewModel
light it also has one clear and defined goal: to provide a domain api to my view. Everything else is handled by the view itself. Using these Converter
classes also achieve having a reusable and testable code.
ViewData
Here ViewModel is quite dumb 🙃. It is no more a domain model but actually just a mapper for the view. It is either exposing a 1-1 mapping for the view or it is doing the mapping directly (basically the bind
method we defined just before).
class BookViewData {
private let book: Book
private formatter: DateFormatter
var title: String { book.title }
var publishedAt: String { formatter.string(from: book.publishedAt) }
}
In any case you will still need someone to get the models used by those ViewData and perform the actions. People seem to often call them Interactor : they will more or less have the same role as our ViewState classes.
ViewState or ViewData?
I do prefer seeing ViewModel as the view state so I would recommend you to stick to ViewState pattern.
Moreover the view stuff can be splitted and reuse across the whole when needed using Converter
which make me feel that ViewData is an unnecessary level of abstraction. But in the end the most important is to make a choice, so choose one and stick to it into your whole application!