Long story short – we had an iOS app, written in Objective-C. A huge one, with many, many issues. We decided to rewrite it in Swift – for various reasons, but will of having clean architecture was one of them. Have we succeeded? Read on 😉
Since the beginning, we knew what we wanted to achieve – at least in terms of high-level goals. Our app is build of „modules” – by which we treat a single position on the tab bar. But some of those modules have „submodules”, others are accessible in a different way than tapping on tab bar icon.
Additionally, the plan was to be able to create different applications, containing a few common modules, some specific to this app, some common, but with specific UI.
Lastly, with experience from Objective-C app we knew, that for any business flow we need to have „one source of truth” – a place where data is fetched from – and similarly one place where data gets mutated and saved.
In a very first step, we started dividing existing application to logical units/blocks of code. To have a clean mindset we abandoned name „module”, which was used strictly to tab bar items in the old app, and started to use name „component”, by which we mean some unit of business (or application) logic.
For example, a tab bar item can be composed of several components. Setting screen accessible from navigation bar could be a separate component. Some background task manager – another component, even if it doesn’t have any UI.
That was our first kind of „brick” – of course, we extracted common parts of a component to some abstract parent types or protocols. We called it „ComponentCore”, every other component had to inherit or implement its protocols.
So far so good – we had units of logic divided. However, we still had to „glue” them into an app. It was not a single one – the same components could be injected to different apps. That’s where Client concept comes in – it is just about having an app, which follows some patterns we defined. Similarly to the road we took for components, we created „ClientCore” – some abstract protocols that defined basic usage of components. The important thing is that Client – as we called it – knows about existence of some abstract Components (from „ComponentCore”), but it does not know anything about any special component.
ClientCore also provides things like root view controller, navigation handling and so on.
It proved to be so generic, that most of the Client app consists of just two swift files – AppDelegate and Configuration, which is defining which components should be loaded, some backend address etc. Then Configuration is injected to generic AppController, it generates main view controller and app is up and running.
Next type of brick is „utility” – all kinds of helpers, reusable code, extensions, UI elements, networking etc. The rule we have here that they cannot contain any component- or client-specific references. They must be generic.
The last item to whole set above is DataCore – do not confuse with CoreData 😉
Our apps cannot exist without some specific entities – they would be simply useless. For example, we need to have User concept. For that reason, we created DataCore, which manages such entities.
Making it work
We created separate repositories for all „Core” types described above. Every component has its own repository as well. All of them are existing as separate Pod. Finally, we have one repository per Client, which integrates all dependencies via Podfile and manages app Configuration file. In this way, we can quickly add or remove components for a specific Client.
Ideally, to maintain such architecture, one component cannot know anything about the existence of any other component – including its existence.
Still, in our app components are exchanging data – and that’s a must-have.
Upon instantiating component, we create “DataProvider” object, which is registered into “SharedDataProvider” – a place that gathers all “DataProviders”, living as part of AppController.
SharedDataProvider is responsible for retaining specific data providers and directing queries to the correct one.
Query is a small struct containing the identifier and optional parameters.
SharedDataProvider returns response as encoded JSON.
In short, SharedDataProvider acts as a server, DataProvider as micro-service, and component requests data in REST-like pattern.
Why it is scalable?
Our app is developed in a few locations in Europe, by about 20 developers, yet only 3 of them are from the beginning of this „rewrite into Swift” history.
So far we have about 20 different components, created at the same time by different sub-teams. They have the same „interface” (thanks to ComponentCore), yet they can be implemented in different ways inside them – could be MVC, MVVM, VIPER or nearly one view controller.
If we want to rewrite component in different architecture – and that happened already – we don’t interfere with any other teamwork, as long we conform to protocols defined in ComponentCore.
Components don’t know anything about any other one – still, they can share or request information from other ones, thanks to being loaded via AppController, which manages such communication – still it does not know anything about any specific component.
If a customer wants a new client app with specific components only – we can create that in minutes. We could also run any component as a standalone app.
Should I adapt my app to something similar?
That depends 😉
If you want to adapt app that does not have clearly visible boundaries for component, or component existence without any other component won’t be possible – such architecture won’t be that helpful.
In case you have a big monolith app, where changes from each developer are interfering with others – that’s definitely worth a try.