In this post, I will discuss microfeature architecture that is, simply said, amazing when implemented correctly in an iOS app.
- Ideas on implementation with SwiftUI
- Using tuist to structure microfeature application
The idea comes from microservice server-side application infrastructure. The whole app is divided into logical components corresponding to different functional areas of the application.
Briefly, microfeature architecture implies splitting your app into different components that accept other components' interfaces or data as explicit dependencies.
Therefore, your app can be represented as a graph of modules that explicitly interact with each other.
- Improved maintainability — each component is small and so is easier to understand and change.
- Better testability — components explicitly define their public interface. So, they are easier to mock and test.
- Team organization — different teams can work on different components independently.
- Scalability, code reuse —when an app is a combination of modules, you can robustly change the app's behaviour by recombining modules. If you decide to create an app extension, watchOS app, or App Clip, just pick the required components and you're all set up.
- Explicit dependencies — implicit dependencies are one of the worst things that can happen to an app's architecture. This architecture requires defining explicit dependencies for each module.
So, how to structure an iOS app once you decided to use microfeature architecture? The core concept is separation. But you still can use one Xcode project for that and separate features purely by architecture.
I will cover how to do this effectively with tuist in the next episode!
Your codebase will be divided into several blocks:
That's where elements of your app live. Later in this post, I will show by example what this part includes.
Components are logical blocks of your app. Each component explicitly defines an interface to interact with it.
You can have a WatchOS app, widgets, and the main iOS app. Each app depends on features and builds the final app using features, combining them like bricks.
Tests + Testing Data And Mock
This logic also lies apart from the feature's main parts. It's separate because:
- We don't want to use mock data accidentally in the app
- We don't want to include irrelevant data in the final app binary
The feature consists of four blocks. Tests and mocks may not be present, but the feature always has an interface and implementation.
This part defines parts visible for other features. Public interfaces and models or entities of the feature stay here.
Interfaces define ways that are used to interact with the feature.
Models or entities are simple structures with almost no logic that simply define data used to communicate with the feature.
You can include other components in the interface but remember that interface must not expose implementation details
Features must not depend on other feature's implementation
Implementation depends on an interface and provides classes and structures conforming to defined protocols in the interface. Resources, images, and other implementation details also stay here.
Dependency inversion happens naturally when other modules know about interfaces and not about implementations.
Knowing this information, we can add details to our app's graph image:
Notice that none of the features depends on the other feature's interface. Each feature interface strictly depends on the other feature's interface.
Now you see that apps take building blocks and combine them to make an app.
Let's architect a scheduling app. It will have:
- Schedule view
- Add event/edit view
- Schedule WatchOS View
Let's split this app into several features:
Contains common UI elements that can be used to create more complex views
Contains main schedule views and logic associated with them. The interface defines ways to interact with views or present them.
Contains watch-specific schedule views and logic associated with them
Contains event modification logic and views
Data provider. Defines data structures and entities to obtain them.
The interface will contain simple data entities and model protocols defining ways of obtaining these entities.
Implementation defines models conforming to protocols defined in the interface. For example, you may want to define a local storage model or network model. It's up to the final app to decide which option to use.
As you see, WatchOS and the main iOS app reuse common components. Also, Each app decides which implementation of modules' interfaces they pick. For example, the WatchOS app can choose different data sources in ScheduleData feature rather than the main iOS app.
In a monolithic app, you would probably need to write almost a second app and copy a lot of code
In the next posts, I will share my ideas on using microfeature architecture with SwiftUI and tuist to structure code efficiently.
When should I create a new feature and when It's better not to?
It purely depends on the case and on what you think the best option is. If you can come up with some use case when your feature will be reused in some other context, then it's a separate feature.
Making a new feature for each class will do more harm than good.
If some block probably will not be reused, but you just feel that it's logically separate functionality, then also go with a new feature as it will help to keep your architecture clean.
What to do with circular references?
Circular references can be a pain and they happen if two features depend on each other's interfaces. If such a situation happens, critically consider if your feature separation is correct. There are two possible options.
- Two features are actually one feature. Then, you can merge these two features and get rid of circular references.
- Two features are actually three features. If features depend on each other, then there is some part that's needed by both features. What if this part is an independent feature? If this is the case, extract the third feature and fix dependencies.
There's a lot said about making dependencies explicit. What's the point?
It's nearly impossible to scale or modify big apps when components are implicitly dependent. Just imagine the mess that is going to happen if you modify some class that is a dependency of all other modules through a singleton.
Your app may start to have unexpected behaviour here and there and you can't even know how your modification will affect the whole app.
It's like sitting on a box of TNT.
I encourage you to avoid implicit dependencies whenever possible. Microfeatures architecture will help you with doing that.