Eliminating cross-cutting concerns with Decorator pattern
Cross-cutting concern is a requirement of the system that has to be satisfied in multiple places of the application. For instance, if we are to query the server with and without a token, we have to keep in mind that we have to somehow store/retrieve an access token. Thus, this entire logic gets scattered all around the codebase and becomes messy. Likewise, analytics is a cross-cutting concern that is used even more often, than token usage.
In this article, we will examine how analytics can be used across the entire iOS application without the need for keeping the dependency in the ViewController. For simplicity, we will consider plain MVC (it works the same for MVVM, MVP, MV* patterns) as a UI design pattern.
To illustrate the example clearly, we will consider two different implementations of the same mechanism.
TL; DR
- Abstract the existing interface with the wrapper
- Inject a component with the same interface
- Use the component to keep the existing behavior
- Add additional behavior and forward the message further
- Compose types in a single place
Initial Setup
The example that we are going to examine is a simple screen that loads a list of strings. We load strings via LoaderService interface that has a single method for loading and can have any implementation we would choose (we are not considering implementations here). For logging analytics events, we would also use a plain interface AnalyticsEngine and the implementation of it would log it to whatever service (Firebase, Custom SDK and etc).
Dependency diagram
//LoaderService interface
protocol LoaderService {
func load(completion: @escaping Result<[String],Error>
}//Event that is going to be logged
protocol AnalyticsEvent {
var name: String { get }
}//AnalyticsEngine interface
protocol AnalyticsEngine {
func log(event: AnalyticsEvent)
}
The blueprints of our interfaces are quite simple. Let’s examine how we can load and use it on our screen.
Example 1 — No decorator pattern
import UIKitclass SampleViewController : UIViewController { private let loader: LoaderService
private let analytics: AnalyticsEngine init(loader: LoaderService, analytics: AnalyticsEngine) {
self.loader = loader
self.analytics = analytics
} override func viewDidLoad() {
super.viewDidLoad() loader.load { [weak self] result in
switch result {
case .success:
self?.analytics.log(event: AnalyticsEvent(name: "Loaded list successfully")
case .failure:
self?.analytics.log(event: AnalyticsEvent(name: "Failed to load a list")
}
}
}
SampleViewController simply loads a list of strings and logs an appropriate message to whatever analytics service.
Problem:
The actual problem is in the fact that SampleViewController is already doing too much by loading the view, loading list of items and logging to the analytics service.
Additional problem:
Since LoaderService loads data asynchronously from network, cache, filesystem and etc., we have to somehow dispatch to the main thread to prevent app crashing.
import UIKitclass SampleViewController : UIViewController { private let loader: LoaderService
private let analytics: AnalyticsEngine init(loader: LoaderService, analytics: AnalyticsEngine) {
self.loader = loader
self.analytics = analytics
} override func viewDidLoad() {
super.viewDidLoad() loader.load { [weak self] result in
switch result {
case .success:
self?.analytics.log(event: AnalyticsEvent(name: "Loaded list successfully")
DispatchQueue.main.async {
//update UI on main thread
}
case .failure:
self?.analytics.log(event: AnalyticsEvent(name: "Failed to load a list")
}
}
}
Suddenly, ViewController starts doing even more. We were simply interested in loading the list of items and update the UI, yet the design already got so much complicated.
Example 2 — Decorator pattern in action
Reworked dependency diagram:
LoaderServiceAnalyticsDecorator implements LoaderService interface and has a direct dependency on Analytics. Let’s examine how did the code and responsibilities change by adding this simple abstraction.
class LoaderServiceAnalyticsDecorator: LoaderService {
private let decoratee: LoaderService
private let analytics: AnalyticsEngine init(decoratee: LoaderService, analytics: AnalyticsEngine) {
self.decoratee = decoratee
self.analytics = analytics
} func load(completion: Result<[String],Error>) {
decoratee.load { [weak self] result in
switch result {
case .success:
self?.analytics.log(event: AnalyticsEvent(name: "Loaded list successfully")
case .failure:
self?.analytics.log(event: AnalyticsEvent(name: "Failed to load a list")
}
//Forward the message further
completion(result)
}
}
}
decoratee is a component that we are interested in decorating. Since our screen relies on the loader to load items, we are interested in extending the behavior of the component without the need to change our controller. This is the illustration of Open-Closed principle from SOLID. The most important factor here is to just forward the message further while inspecting intermediate results. Therefore, we can inject any behavior we want indefinitely.
Let’s see what has changed in the view controller.
import UIKitclass SampleViewController : UIViewController { private let loader: LoaderService init(loader: LoaderService) {
self.loader = loader
} override func viewDidLoad() {
super.viewDidLoad() loader.load { [weak self] result in
switch result {
case .success:
DispatchQueue.main.async {
//update UI on main thread
}
case .failure:
//Do something else
}
}
}
We can easily see that controller is not concerned on logging any analytics event. Thus, we can compose our controller with the decorated service that can log analytics in between.
let loader = LoaderService() //dummy service
let analytics = AnalyticsEngine() //dummy analytics
let decoratedLoader = LoaderServiceAnalyticsDecorator(decoratee: loader, analytics: analytics)let viewController = SampleViewController(loader: decoratedLoader)
Since LoaderServiceAnalyticsDecorator implements the same interface that our SampleViewController uses, Swift can infer the type for us.
Having eliminated the need to use the analytics in the view controller, we still have to deal with threading. Let’s see current responsibilities of our screen:
We have eliminated the logging from our controller, but can we still eliminate threading and lighten up the controller?
More, more abstractions
Adding one more layer of abstraction in between the controller and the loader can be handy.
MainQueueDecoratorLoaderService implements the same well-known LoaderService protocol and just deals with dispatching to the main thread.
Note: SampleViewController still depends on LoaderService and has no idea about decorators that are injected from outside.
class MainQueueDecoratorLoaderService: LoaderService {
private let decoratee: LoaderService init(decoratee: LoaderService) {
self.decoratee = decoratee
} func load(completion: Result<[String],Error>) {
decoratee.load { [weak self] result in
//Check that we are on main thread
if Thread.isMainThread {
completion(result)
} else {
DispatchQueue.main.async {
completion(result)
}
}
}
}
}
MainQueueDecoratorLoaderService just checks if we are already on a main thread so as not to overload the queue and dispatches otherwise. Nothing fancy going on.
Let’s get back to our view controller:
import UIKitclass SampleViewController : UIViewController { private let loader: LoaderService init(loader: LoaderService) {
self.loader = loader
} override func viewDidLoad() {
super.viewDidLoad() loader.load { [weak self] result in
switch result {
case .success:
//update UI
case .failure:
//Do something else
}
}
}
Wow! Our screen became so much simpler, nothing to log or dispatch to main thread. Pure loading in its essence!
Responsibilities of the controller after we have decorated the loader:
So simple. It should just load data from somewhere and load the view.
Composing all types together:
let loader = LoaderService()
let analytics = AnalyticsEngine()
let mainQueueLoaderDecorator = MainQueueDecoratorLoaderService(decoratee: loader)
let decoratedLoader = LoaderServiceAnalyticsDecorator(decoratee: mainQueueLoaderDecorator, analytics: analytics)let viewController = SampleViewController(loader: decoratedLoader)
We simply decorate the initial loader with the MainQueueDecoratorLoaderService and pass the mainQueueLoaderDecorator to the decorator with analytics. How simple it is just to compose types!
Composition is the key
Composing types is an essential part of using Decorator pattern. As we have seen, we can just swap instances, while our view controller stays untouched. Controller is concerned just about loading and displaying data on the screen as it was designed to do so. Composing all the types in a single place is the key to achieving loose coupling and flexibility.