Leonardo Maia Pugliese
Holy Swift

Holy Swift

Using decorator pattern to add architectural non-intrusive analytics in Swift

Using decorator pattern to add architectural non-intrusive analytics in Swift

The good architecture traits

Leonardo Maia Pugliese's photo
Leonardo Maia Pugliese
·Jan 18, 2022·

12 min read

Subscribe to my newsletter and never miss my upcoming articles

Hallo alle mensen, Leo Hier.

Today we will talk about a little more advanced architecture topic. We will discuss cohesion and coupling while we will walk through all the solutions. The most important lesson today is that we will apply the Separation of Concerns (SoC) principle into our architecture.

When we all start coding it's pretty common to put everything in one place. When the young developer starts to gain experience, he/she learns that it's important to separate concerns, therefore the code is easier to test and maintain. Also is important to split things logically because you increase the readability of your code and when you do that your future you or your future colleagues will be grateful.

We will not cover method swizzling magic here, and yes is another way to add non-intrusive analytics to your app. But be aware that can also lead to undesired and unpredictable behaviors of your app. Be careful.

Let's begin with this architectural journey through SoC and decorator pattern.

But first... the painting.

The Painting

This is an art piece by the master Vincent Van Gogh, called Bridge and Houses on the Corner of Herengracht-Prinsessegracht.

Van Gogh is generally considered the greatest after Rembrandt, and one of the greatest of the Post-Impressionists. The striking color, emphatic brushwork, and contoured forms of his work powerfully influenced the current of Expressionism in modern art.

I chose this work because I love the architecture where I live now. It's so nice to walk around the neighborhood and see beautiful houses with great architectural coherence.

The Problem

You want that your architecture sends some analytics data but don't want that change the behavior of pre-existing structures.

So you start your Monday and your Product Owner starts the daily meeting saying: "I want to have analytics data on all of our login". Your brain doesn't even let him/her finish the thought train and start to think about how can you add analytics to your project.

Today we will start with a very very basic structure, and we will evolve to a more intricate one on each step. Disclaimer: this is not a final solution for every problem, everything can and should be adapted for your context and needs. Take it as food for thoughts.

Implementations

We will start with how basic implementations occur.

Implementation Level 1 - The massive View Controller

As said before when we start our developers' life, we tend to put everything in the same place. It's not unusual to have this kind of class:

class HomeViewController {

    // all views configurations

    // all class configurations

    // hundreds (even thousands) of lines of code 

    func doLogin(username: String, psw: String) {
        // do all analytics part here
        // do all the async login logic here
    }
}

The architecture design is this:

Screenshot 2022-01-17 at 08.28.05.png

As you can see the HomeViewController is doing too much. It is solely responsible for: Creating the views, configuring the views on the screen, managing the views lifecycle, doing the login, doing the analytics and I'm sure that you know how extensive this list can become.

You get the point.

And then it's amazing because all this code works. The Swift compiler doesn't care if it's everything in one place. Although I've already heard a story that a guy who put more than 10k lines of code in a view controller and the Xcode didn't open it anymore without crashing and he had to open that file in another source editor. Lol.

In short, you should always look up things that start to become giant in your source code. This is always a smell that you could refactor into smaller and easier pieces to maintain. As a rule of thumb if you have a function that is bigger than your screen maybe it's time to break it into smaller functions. Of course, take it into account if you have a giant screen or a very small font in your Xcode but you get the idea.

The first refactor will extract the code from the ViewController. Let's do that.

Implementation Level 2 - Splitting into Logic Layer and UI Layer

Now we start to separate the concerns. Here is a good place to talk about that design principle. Let's start with a wiki's definition:

In computer science, separation of concerns (SoC) is a design principle for separating a computer program into distinct sections. Each section addresses a separate concern, a set of information that affects the code of a computer program.

This technique results in more freedom degrees for some aspects of our app's design and maintainability. When concerns are well-separated, there are more opportunities for module upgrades, reuse, and independent development.

Hiding the implementation details of modules behind a protocol enables improving or modifying a single concern's section of code without having to know the details of other sections and without having to make corresponding changes to those other sections.

The disadvantage is that when our interfaces change, we have to change everything that depends on it, but this is something we already have to do without the protocol communication.

So now our code looks like this:

class HomeViewController {

    let homeViewModel: HomeViewModel = HomeViewModel()

    func doLogin(username: String, psw: String) {
        // analytics logic here
        homeViewModel.doLogin(username: username, psw: psw) { result in
           // analytics logic here
            print("The login of \(username) was : \(result ? "successful" : "failed")")
        }
    }
}

class HomeViewModel {
    func doLogin(username: String, psw: String, completion: @escaping (Bool) -> Void) {
        // all the rest of the logic to login or not a user

        let loginResult = Bool.random()
        completion(loginResult)
    }
}

Now our design looks like this:

Screenshot 2022-01-17 at 09.07.37.png

Way better right? Yes of course it's better. But it could be improved. Remember the separation of concerns principle? Well, if we want to apply it we have to do two things.

One makes all the communication with other modules through a common protocol and the other one is to enable the HomeViewController to receive all its external dependencies.

Let's do it.

protocol HomeViewModelService {
    func doLogin(username: String, psw: String, completion: @escaping (Bool) -> Void)
}

class HomeViewController {

    let homeViewModelService: HomeViewModelService

    init(homeViewModelService: HomeViewModelService) {
        self.homeViewModelService = homeViewModelService
    }

    func doLogin(username: String, psw: String) {
        homeViewModelService.doLogin(username: username, psw: psw) { result in
            print("The login of \(username) was : \(result ? "successful" : "failed")")
        }
    }
}

class HomeViewModel: HomeViewModelService {
    func doLogin(username: String, psw: String, completion: @escaping (Bool) -> Void) {
        // all the rest of the logic to login or not a user

        let loginResult = Bool.random()
        completion(loginResult)
    }
}

Now the architecture looks like this:

Screenshot 2022-01-18 at 07.41.08.png

Our architecture is now starting to be flexible. We have decoupled the login logic from the UI Logic. Almost every architecture out there has at least those two layers of separation of concerns.

The advantages are: if/when the logic changes you don't have to change UI code. In the same way, if the UI changes you don't have to change the application logic code.

Meanwhile, the most notable feature that you get injecting all the external resources through initializers is the easy Unit Test capabilities. Now if you want to unit test even the HomeViewController you can do it easily just by creating a stub object of its initializer protocols. This is subject for other posts .

Let's continue to evolve our architecture.

Implementation Level 3 - Adding a service layer

Although we already achieved an interesting level of architecture in the UI layer, injecting all the external dependencies into the HomeViewController we can go even further.

If you check the HomeViewModel now it has to know how to log in a user. This is not very good for the rest of our app. Imagine that other modules of the app also have to know how to log in a user?

This is why we will now separate the login logic from the HomeViewModel. The strategy will be the same we used in the HomeViewController, we will create and inject all the new external dependencies through protocols. First, we will create the LoginService protocol and a struct to conform to it.

See the code below:

protocol LoginService {    
    func doLogin(username: String, psw: String, completion: @escaping (Bool) -> Void)
}

struct LoginServiceImpl: LoginService {

    func doLogin(username: String, psw: String, completion: @escaping (Bool) -> Void) {
        // all the rest of the logic to login or not a user

        let loginResult = Bool.random()
        completion(loginResult)
    }
}

Now adapt your HomeViewModel to the new LoginService:

struct HomeViewModel: HomeViewModelService {

    let loginService: LoginService

    init(loginService: LoginService) {
        self.loginService = loginService
    }

    func doLogin(username: String, psw: String, completion: @escaping (Bool) -> Void) {
        loginService.doLogin(username: username, psw: psw, completion: completion)
    }
}

At this point you can already test your architecture using:

let loginService = LoginServiceImpl()

let homeViewModel = HomeViewModel(loginService: loginService)

let homeViewController = HomeViewController(homeViewModelService: homeViewModel)

homeViewController.doLogin(username: "Pepijn", psw: "123456")

Resulting in:

Screenshot 2022-01-18 at 08.45.14.png

The result is random so it's not relevant for our study today.

Now the app architecture looks like this:

Screenshot 2022-01-18 at 08.07.05.png

So far it's ok. See how the arrows just point to the inner layers, no dependencies are going up. This is what clean architecture looks like. The outside layer just knows one layer below it. As described in this brilliant post.

Screenshot 2022-01-18 at 08.21.09.png

Although we created a problem. To build the HomeViewController the composition root of it should know the login service, the HomeViewModelService, and the HomeViewController. This is not very nice to handle architecture and could become a problem in the future. And if we draw the composition root of our architecture now, it turns out to be like this:

Screenshot 2022-01-18 at 08.09.15.png

We will not attack this problem today, but for sure in a future post, we will talk about how to clean the composition root. But be aware that this is also a problem to be solved.

Now let's add start to solve the problem statement.

Implementation Level 4 - Where do we put the Analytics?

Now we have that sweet three layers architecture. We now have to answer the question to the problem. How will we add analytics into this architecture?

Let's think about all the layers:

We have the UI Layer:

Screenshot 2022-01-18 at 08.26.11.png

If we put the analytics there we could respond to each user interaction and the response from the server. Is this true? We don't know. Because the UI Layer only talks with the ViewModel layer it doesn't know if the view model is using a real login or a fake offline login. Also, we don't know if the error messages were mapped to something else. Finally, if we add here we are coupling UI Logic to analytics logic, and this is not wanted, right? Remember the separation of concerns.

Next, we have the View Model Layer:

Screenshot 2022-01-18 at 08.27.26.png

This class is responsible to have all the data that is crucial for the UI Layer to build. It can map a type to another type, can orchestrate a lot of services, can check caches, implement Location Services or DataSources, etc. If we add the analytics here, it will be another responsibility for this class. Should this class be responsible to provide all the view data and also the analytics? It can be. Although is not the best solution for our case you can use this layer to log analytics. But if you use this layer know that you will be coupling the view state with login analytics, and doesn't sound beautiful right?

And finally, we have the Service Layer:

Screenshot 2022-01-18 at 08.34.51.png

The LoginService is responsible to have the logic to log in to the user. Having the analytics here implies that everything within the app would be logged. If/when other modules start to use this service module, we will have a side effect that they also will be logged. And this is not a good architectural trait. Remember: all of our architectural decisions have side effects, the difference between a good and a bad side effect is how easy to maintain/handle in the feature it is. So as we don't want that undesired side effects we will discard put the analytics logic there.

The solution

Using decorator patterns you can solve this problem in a non-intrusive way. While you keep the maintainability and flexibility of your code, you will add analytics only in the calls from the HomeViewModel.

To use the decorator pattern you just need to create a struct that conforms to the LoginService protocol and you can decorate it with your logging functions. For example:

struct LoginServiceAnalytics: LoginService {
    var isUserLoginValid: Bool = false
    let loginService: LoginService

    func doLogin(username: String, psw: String, completion: @escaping (Bool) -> Void) {
        log(text: "[\(username)] User login attempt")
        loginService.doLogin(username: username, psw: psw) { loginResult in
            log(text: "[\(loginResult) was the result of the login]")
            completion(loginResult)
        }
    }

    func log(text: String) {
        // this could be any logging provider ( just imagine a login provider here)
        print("\(Date()) - \(text)")
    }
}

This will be our Analytics Service. It will go in the service layer because it just needs to know the LoginService.

The final architecture draw is this:

Screenshot 2022-01-18 at 08.51.59.png

And the full code can be found below, copy and paste to your Playgrounds and have fun!

protocol LoginService {
    func doLogin(username: String, psw: String, completion: @escaping (Bool) -> Void)
}

protocol HomeViewModelService {
    func doLogin(username: String, psw: String, completion: @escaping (Bool) -> Void)
}

class HomeViewController {

    let homeViewModelService: HomeViewModelService

    init(homeViewModelService: HomeViewModelService) {
        self.homeViewModelService = homeViewModelService
    }

    func doLogin(username: String, psw: String) {
        homeViewModelService.doLogin(username: username, psw: psw) { result in
            print("The login of \(username) was : \(result ? "successful" : "failed")")
        }
    }
}

struct HomeViewModel: HomeViewModelService {

    let loginService: LoginService

    init(loginService: LoginService) {
        self.loginService = loginService
    }

    func doLogin(username: String, psw: String, completion: @escaping (Bool) -> Void) {
        loginService.doLogin(username: username, psw: psw, completion: completion)
    }
}

struct LoginServiceImpl: LoginService {

    func doLogin(username: String, psw: String, completion: @escaping (Bool) -> Void) {
        // all the rest of the logic to login or not a user

        let loginResult = Bool.random()
        completion(loginResult)
    }
}

struct LoginServiceAnalytics: LoginService {
    var isUserLoginValid: Bool = false
    let loginService: LoginService

    func doLogin(username: String, psw: String, completion: @escaping (Bool) -> Void) {
        log(text: "[\(username)] User login attempt")
        loginService.doLogin(username: username, psw: psw) { loginResult in
            log(text: "[\(loginResult) was the result of the login]")
            completion(loginResult)
        }
    }

    func log(text: String) {
        // this could be any logging provider ( just imagine a login provider here)
        print("\(Date()) - \(text)")
    }
}

let loginService = LoginServiceImpl()
let loginServiceAnalytics = LoginServiceAnalytics(loginService: loginService)
//
let homeViewModel = HomeViewModel(loginService: loginService)
let homeViewModel2 = HomeViewModel(loginService: loginServiceAnalytics)

let homeViewController = HomeViewController(homeViewModelService: homeViewModel)
let homeViewController2 = HomeViewController(homeViewModelService: homeViewModel2)

homeViewController.doLogin(username: "Ana", psw: "123456")
print("\n\n")
homeViewController2.doLogin(username: "Mike", psw: "123456")

With the final result of:

Screenshot 2022-01-18 at 08.55.13.png

And that's it! Did you realize how easy was to add an analytics layer since our architecture was hard to change and was very welcome to new features? All because we used protocol-oriented programming and one design pattern. How great this was?

Summary

Today we explored how you can use a clean architecture with a design pattern to solve an analytics problem. This post used a very basic architecture and we evolved into a more clean sophisticated one. I hope this helps everyone to see the importance of using known patterns and protocols to make your and your developer colleagues easier.

That's all my people, I hope you liked it as I enjoyed writing this article. If you want to support this blog you can Buy Me a Coffee or just leave a comment saying hello. You can also sponsor posts and I'm open to writing freelancing! Just reach me in LinkedIn or Twitter for details.

Thanks for the reading and... That's all folks.

credits: image

Did you find this article valuable?

Support Leonardo Maia Pugliese by becoming a sponsor. Any amount is appreciated!

Learn more about Hashnode Sponsors
 
Share this