Slaying the monolith: API/Implementation modularisation pattern in Android development

Sean Coyle
ASOS Tech Blog
Published in
9 min readJul 19, 2023
Image generated by DALL-E

Traditionally, many apps began as a single module, growing over time into a monolith. This was also the case with our Android app at ASOS, where years of feature development led to a complex, monolithic architecture. Such a structure can pose significant challenges, including prolonged build times, reduced code reusability, and a lack of encapsulation and separation of concerns. For example, consider an app that has evolved to include features like user authentication, image processing, and data analysis. As these functionalities amass within a single module, the system can become unwieldy and difficult to manage. To combat these issues, many developers turn to modularisation — the practice of breaking down an application into smaller, manageable, and interchangeable parts known as modules. This approach is particularly useful in managing complexity, improving code maintainability, and facilitating scalability. In the world of Android development, modularisation has become an indispensable architectural tool.

Modularisation is akin to building with blocks — each block, or module is independent, reusable and serves a specific purpose. When these modules are assembled together, they form a complete application. There are various ways to modularise your app, including by feature, layer, library etc.

In this article, we will focus on modularising by feature following the API (Application Programming Interface) / Implementation pattern and discuss why it’s a good choice for dismantling monoliths.

A deeper understanding of the API/implementation pattern

At its core, the API/Implementation pattern is a manifestation of the dependency inversion principle from the widely adopted SOLID principles. This pattern advances the idea of high-level module stability and low-level module changeability, which means we design our modules in such a way that the high-level modules remain stable, and the low-level modules are amenable to changes as required. To achieve this, each feature module within the application is split into two sub-modules:

This image displays an example module diagram showing an example of the api/implementation pattern. The diagram starts with an app module which depends on a monolith module. The monolith module then depends on three feature modules API sub modules. The diagram also depicts feature module A’s implementation sub module depending on Feature module B’s API sub module.
Example module structure
  1. API Module: The API module represents a set of interfaces that the module exposes. These interfaces form the contract that other modules in the application will interact with. It’s through these contracts that higher-level modules can utilise the functionality provided by the lower-level modules. In simpler terms, the API module outlines what a particular module can do without getting into the details of how it does it. This module also contains domain models and any DI (dependency injection) entry points if required.
  2. Implementation Module: This is where the concrete implementation of the API interfaces resides. The implementation module is the working engine that brings to life the promises made by the API. The implementation is essentially the details, and by the principle of dependency inversion, it should be the part that is more prone to change. This module will always depend on its own API module in its gradle file.

The above diagram demonstrates the transition from a monolithic architecture to one utilising feature modules. The original monolith interacts with the feature modules via their respective API modules, which underscores the goal of maintaining these feature modules as loosely coupled and independent entities. However, depending on the complexity of your app, achieving absolute independence for each module might not always be practical or necessary. As indicated in the diagram, feature module A also accesses the API of feature module B. It’s important to strike an effective balance between the independence of your modules and the specific needs of your project.

Coupling and cohesion

By breaking down the application into feature modules with clearly defined APIs, we promote loose coupling. Each module only needs to interact with other modules through their APIs, without being tightly coupled to their internal implementation. This allows modules to evolve independently, facilitating flexibility, maintainability, and ease of modification.

Cohesion, on the other hand, refers to the degree to which the elements within a module are related and work together to achieve a common purpose. High cohesion means that the elements within a module are strongly related and focused on a specific functionality or responsibility.

The API/Implementation pattern encourages high cohesion within modules. Each feature module has its own API module that defines its contracts and interfaces, encapsulating the essential behaviour related to that feature. The implementation module provides the concrete realization of the API’s contract. This separation of concerns and clear division of responsibilities enhances the cohesion within each module, making the code more understandable and maintainable.

API and its purpose

The API encapsulates the essential behaviour of the module, without exposing any of the implementation details. A significant benefit of this pattern is that when the logic in the implementation module changes, it won’t trigger a full recompile of other modules as the other modules don’t know anything has changed. Let’s illustrate this with an example from a hypothetical “Music Player” app. In this application, one of the features could be playing a music track. The API for this feature might look something like this:

interface MusicService {
fun play(track: Track)
fun stop()
fun pause()
fun resume()
}

These are the operations that a consumer of the MusicService needs to know to play music. It doesn't matter how the music is played – only that these actions can be performed.

Implementation and its role

The Implementation part of the pattern represents the concrete realisation of the API’s contract. This is where the specifics of how the operations are carried out get defined. The implementation of the MusicService interface could look like this:

internal class MusicServiceImpl @Inject constructor(
private val mediaPlayer: MediaPlayer
) : MusicService {

override fun play(track: Track) {
mediaPlayer.setDataSource(track.filePath)
mediaPlayer.prepare()
mediaPlayer.start()
}
override fun stop() {
mediaPlayer.stop()
mediaPlayer.reset()
}
override fun pause() {
mediaPlayer.pause()
}
override fun resume() {
mediaPlayer.start()
}

}

Here we can see how the operations defined in the API are realised. The MusicServiceImpl uses the Android MediaPlayer class to play music, but consumers of the MusicService don't need to know that. This kind of encapsulation is one of the key advantages of the API/Implementation pattern.

Bridging API and implementation: dependency injection

The glue that binds the API and Implementation together is typically a form of dependency injection. A DI library like Dagger, Hilt, or Koin helps manage dependencies and decouple the creation of objects from their use.

In our music player app, you would set up your DI system to provide an instance of MusicServiceImpl whenever a MusicService is needed. This way, the consumers of MusicService (the higher-level modules) don't need to know about MusicServiceImpl (the lower-level module). This decoupling allows you to change MusicServiceImpl in any way, as long as it still follows the MusicService contract, without affecting the consumers.

@Module
@InstallIn(SingletonComponent::class)
internal abstract class MusicModule {

@Binds
abstract fun bindsMusicService(impl: MusicServiceImpl): MusicService

}

Navigating the hurdles

While embarking on the journey of modularisation, you are likely to encounter a few hurdles. Some of the most common ones include:

Cyclic dependency: This occurs when feature module A depends on feature module B, and conversely, module B depends on module A. This circular dependency can be problematic for the build system and hinder the maintainability and readability of your codebase. A practical solution can involve creating an intermediary module that both module A and B depend on. If the shared functionality is common across your app, consider adding it to a ‘Core’ or ‘Library’ module, which can then be shared among all your feature modules.

Gradle Dependency Configurations (api or implementation): These are configurations used to declare dependencies in your Gradle build scripts. When you declare module A as api in module B, and then declare module B as api in module C, module C will have access to the classes in module A. This is because api exposes transitive dependencies to dependent modules. On the other hand, when using implementation, these dependencies are not exposed to other modules that depend on the declaring module. This means that in our scenario, module C wouldn't have access to module A if we use implementation to declare the dependencies.

By default, it’s generally better to use implementation for declaring dependencies. This is because implementation promotes encapsulation, hiding the internal dependencies of a module, which can minimise the impact of changes and improve build speed. However, api should be used when you want to expose a module's dependencies to other modules. This may be useful in cases where you have a shared library or a base module that is used by many other modules.

Finding the leaf: One of the challenges in modularisation is determining where to start the extraction process. It’s generally a good strategy to start with the smallest parts of code that are relatively uncoupled, such as domain models. These ‘leaf nodes’ of your application are often the easiest to isolate and refactor into their own modules. Additionally, taking a closer look at the dependency structure of your code is a useful exercise. Reviewing whether all your classes are injectable with Dependency Injection can help you identify potential areas for improvement. If there are any classes that aren’t currently managed by DI, these could be great starting points. By making these classes injectable, you not only improve the modularity of your code but also pave the way for creating distinct API entry points for your future modules.”

Strengths of the API/implementation pattern

There are several reasons why the API/Implementation pattern is an attractive design choice for Android developers.

  1. Loose Coupling: By enforcing an interaction through interfaces, the pattern ensures that dependencies between modules are minimized, resulting in a loosely coupled system. Loose coupling is essential for code maintainability and flexibility as changes in one module will have a lesser impact on others.
  2. Increased Testability: With a clear separation of interfaces (API) and their implementations, it becomes much easier to perform unit testing. We can mock the interfaces and test the consumers of these interfaces in isolation. This also aids in behavior testing, as we can focus on the expected behavior defined by the API.
  3. Higher Level of Abstraction: The pattern inherently encourages working at a higher level of abstraction, leading to cleaner and more understandable code. It moves the focus from how things are done (implementation details) to what is being done (business logic).
  4. Improved Code Navigation: In large codebases, finding a particular piece of logic can be like finding a needle in a haystack. However, the API/Implementation pattern provides a clear path of navigation. One can first find the required API and then navigate to its implementation.
  5. Faster Compile Times: When we make changes to one of our implementation modules it will not trigger a recompile of other modules as we only expose the API. Other modules in the app won’t know any of the implementation details have changed unless you change the API. This is crucially important in large-scale apps and prevents significantly long recompile times.

Considerations

Despite its strengths, the API/Implementation pattern is not without its downsides.

  1. Added Complexity: While the pattern helps manage complexity in large projects, it also introduces its own layer of complexity, particularly in small projects. The overhead of managing multiple interfaces and their implementations can be taxing and may lead to over-engineering. Creating too many modules can create overhead and negate performance improvements. Striking a balance is key.
  2. Dependency Management: It requires a careful setup of dependencies between modules. One has to ensure that the lower-level modules (implementation) depend on higher-level modules (API) in order to access their functionalities. Any mistakes in this setup could lead to difficult-to-debug and complex issues down the line so it’s crucial to plan carefully.
  3. Layered Architecture within Modules: at ASOS our implementation modules encompass the data, domain, and presentation layers. Some developers prefer further modularisation by segregating these layers into distinct modules. This approach facilitates adherence to clean architecture principles — for instance, the presentation layer shouldn’t bleed into the domain layer, and the domain layer should remain isolated from the data layer. Although this level of modularisation can enhance the separation of concerns and maintainability, it might introduce additional complexity in managing a greater number of modules and their interdependencies.
  4. Steep Learning Curve: For developers unfamiliar with the principles of SOLID and dependency inversion, the pattern could initially seem confusing and could have a steep learning curve.

In conclusion, the API/Implementation modularisation pattern is a powerful tool for structuring an Android application. When employed judiciously, it brings tremendous benefits in terms of maintainability, scalability, and testability. However, it does demand careful planning, thoughtful implementation, and a thorough understanding of the underlying principles. It’s important to balance the benefits of this pattern with the added complexity it introduces, particularly in smaller projects. As with any architectural decision, it’s essential to consider the specific needs and context of your project before adopting this or any other pattern.

I’m Sean Coyle and I joined the ASOS Android team in August 2022. I started my career in Android development in 2019. Before that, I was a social worker and also taught English in Japan for a few years.

--

--