Improving the Android Experience at Primer: Our Journey from Koin to a Custom Dependency Injection Framework

Primer Engineering
primer.io-engineering
9 min readNov 9, 2023

--

Written by Harry Whewell & Semir Zahirovic.

At Primer, our mission is clear: to provide the best possible developer experience for our merchants. This ethos is at the heart of everything we do here, from designing our SDK’s to making critical technical decisions.

Recently, as part of the 2.18.0 Android SDK release, we made a significant change that reflects this commitment — transitioning from the third-party library Koin to a custom dependency injection framework. In this blog post, we’ll take you through our decision-making process, explain the benefits of our custom framework, and demonstrate how this change aligns perfectly with our core principles here at Primer.

A Bit of Background

To provide a solid foundation for understanding the topics discussed in this article it’s essential that we begin with an explanation of what Koin is and the solution it provides, the challenges that SDK developers face when using third-party dependencies, and why these have led us to building our own custom dependency injection framework.

Understanding Dependency Injection & Koin

Dependency injection plays a pivotal role in modern software development, but not all developers may be familiar with it, or the library Koin. So, before we delve into the intricacies, let’s briefly define it and explore its value.

Dependency Injection

Dependency injection is a fundamental concept in software development that involves providing objects (or dependencies) to a class rather than allowing it to create them itself. This method offers several advantages, such as promoting modular and maintainable code, enhancing testability, and making it easier to manage dependencies. In essence, it’s a mechanism that allows components of your application to collaborate without knowing each other’s concrete implementations.

Koin

Koin is one of the most popular open-source dependency injection frameworks for Android and Kotlin development. This popularity is mainly due to Koin being lightweight and easy to use, which are the key reasons why we have been using it to satisfy all our dependency injection needs on the Android SDK at Primer.

This speed and ease of use enabled us to focus on building exceptional features for our merchants — which is always our number one priority. However, as our platform attracted more merchants, we started to encounter a few significant challenges related to the management of third-party dependencies when building an Android SDK.

Challenges in Managing Third-Party Dependencies for an Android SDK

Building and maintaining an Android SDK comes with its own set of unique challenges, particularly when it involves third-party dependencies.

Increased Bundle Size

Firstly, one of the main challenges we face when including third-party dependencies in our SDK is the increase they add to the overall bundle size, this can be a crucial factor, as this can directly impact adoption rates if the bundle size is deemed to be too large by merchants. This is because a large bundle size can result in longer download times and increased storage usage.

Security Concerns

Secondly, security is of paramount importance when dealing with third-party dependencies. SDK developers must stay forever vigilant regarding any potential security vulnerabilities in the libraries they use. This includes monitoring for updates and patches and responding swiftly to any security issues that may arise.

Dependency Clash

Finally, ensuring that the SDK works seamlessly with different versions of third-party libraries is a complex task and one of our key reasons for moving away from Koin. As our SDK’s adoption started to increase we began noticing issues due to the mismatch between the version of Koin used internally on our SDK and the version used by our merchants in their applications.

This is because SDK dependencies on Android are brought into the main app, which means that if the SDK relies on a particular version of a library like Koin, it can potentially clash with the version of that library that the merchant’s app uses. In essence, this means that the SDK and the merchant’s app must agree on which version of Koin to use, as using different versions can lead to compatibility issues and instability in the merchant’s application. So, in this case, aligning with our merchants on the version of Koin became necessary to ensure a smooth and trouble-free experience for them.

Addressing the Issue

Reminding ourselves that our goal is to offer a plug-and-play experience for our merchants, streamlining the onboarding process and minimizing the complexities traditionally associated with integrating new SDKs, our dedicated team embarked on a mission to address the issue.

Why not use a Different Dependency Injection Library?

Initially, we explored alternative solutions, considering a transition of our SDK to a well-established dependency injection library like Hilt, widely recognized as the industry standard for Android development. However, this option presented its own set of challenges.

Hilt requires that all apps that use Hilt must contain changes to the application class. This is usually a straightforward requirement to implement if you are creating an application, but becomes cumbersome when trying to implement it within an SDK or Library that doesn’t have an application class. As such we would have had to ask our merchants to make the changes on their side to account for this, burdening them with additional work and complexity.

This challenge would have also been even more apparent when taking our React native SDK into account, as it would have required non-Android developers, who may not be familiar with the intricacies of Android code, to do the same.

Another point to raise is that Hilt is built upon another dependency Injection library called Dagger which has a larger bundle size than Koin. As mentioned in the previous section, increasing our bundle size has a direct impact on adoption rates so we have to be extremely careful of the amount of third-party dependencies we rely on.

All in all, as amazing as Hilt is for helping Android developers manage Dependency Injection, we felt that for our use case, it came with too many caveats that would have ultimately led to a worse experience for our merchants.

Should we fork Koin?

We contemplated the possibility of forking the Koin library from its source repository. However, this approach presented its own share of limitations and challenges. It would have still led to an expansion of ~400KB to the bundle size, and we would have also been burdened with heightened maintenance efforts to keep the forked library up to date and in sync. There were also certain use cases that a fork of Koin couldn’t have facilitated for us, such as the ability to register and unregister dependencies at runtime.

Building Something Custom

With these considerations in mind, we made the strategic decision to design our custom dependency injection framework from the ground up. Our primary goal was to ensure that the user experience of our SDK remained consistent, and that there were no adverse effects on our merchants’ operations. They could continue to use our SDK in the same way they always had, without any disruption.

Introducing Our New Dependency Injection Framework

Sticking to Our Key Philosophies

Our new Dependency Injection framework is designed to provide a straightforward and efficient way to implement the injection of singletons and factories throughout our Android SDK. When designing the framework we wanted to ensure that we stuck to our key development philosophies which are integral to our software development approach here at Primer.

Make it Maintainable

We believe in the importance of code being maintainable, making it a central focus during the framework’s design. This focus on maintainability ensures that the framework can evolve and adapt which is crucial in the dynamic world of software development.

We also put a great deal of emphasis on writing code that is verbose and easy to understand. This philosophy recognizes that software developers spend a significant amount of their time reading and comprehending code, so we prioritize making our framework’s codebase as clear as possible.

Make it Scalable

Another core development philosophy that we adhered to was ensuring that the code was scalable and adaptable. This philosophy translates into designing the framework in a way that can easily accommodate future growth and changes in our Android SDK. Scalability is crucial for ensuring that the framework remains effective and efficient as our SDK evolves, and adaptability allows us to meet our ever-changing needs.

By adhering to these key philosophies, we not only meet our primary goal of creating a Dependency Injection framework that is simple to use and easy to understand but also ensure that we are creating a software solution that is robust, maintainable, and poised for growth.

Our Solution

In our new framework, dependencies are registered using Container classes, each responsible for a specific set of dependencies. These containers inherit from the abstract class DependencyContainer, which provides all the logic for registering singletons and factories, as well as resolving dependencies. As we wanted to reduce the complexity associated with dependency injection we ensured that all required dependencies could be resolved from a single source of truth, the **sdkContainer** onto which all of the containers are registered.

class PaymentContainer(private val sdk: SdkContainer) : DependencyContainer() {
override fun registerInitialDependencies() {
registerSingleton(PaymentValidator())
registerSingleton(PaymentManager(sdk.resolve(), resolve()))
}
}

The sdkContainer serves as the central hub for resolving dependencies used within the SDK. The sdkContainer is initialized on our base class allowing all the relevant dependencies to be resolved.

Once we have created our Container class, we now need to add it to the sdkContainer so that its dependencies can be resolved within the SDK. We do this within the DISdkContext object.

internal object DISdkContext {
var sdkContainer: SdkContainer? = null
fun init() {
sdkContainer = SdkContainer().apply {
registerContainer(
PaymentContainer(this)
)
// ...
}
}

We have also made great efforts to ensure that resolving dependencies within the SDK is as simple as possible. The DISdkComponent used the DISdkContext object to supply the sdkContainer so we just have to ensure that the class inherits from DISdkComponent and then we can then use the inject() method to lazily inject dependencies.

For example:

class PaymentFragment : Fragment(), DISdkComponent {
private val manager: PaymentManager by inject()
// ...
}

It also possible to inject view models using the same mechanism as described above. We just have to ensure that the class inherits from DISdkComponent and then we can use the viewModel<>() method to lazily inject the view model. All we need to do is provide the view model type along with its factory, and the framework handles the rest.

For example:

class PaymentFragment : Fragment(), DISdkComponent {
private val paymentViewModel: PaymentViewModel
by viewModel<PaymentViewModel, PaymentViewModelFactory>()
// ...
}

Conclusion

In summary, our journey from Koin to a custom dependency injection framework underscores our unwavering commitment to enhancing the user experience for our merchants. We firmly believe that technology should empower, not hinder, and this transition exemplifies that belief.

Our new solution follows many of the same underlying principles and has a lot of the same benefits as using the Koin library. We are using lazy loading for the initialization of the dependencies meaning that they are only initialised at the point when the property is used or called for the first time, ensuring good memory management and stability.

As mentioned above there is also a key ethos around the solution being easy to use and understand, and we have been greatly influenced by the ways we currently use the Koin library when devising our solution. All of which ensures that there is no steep learning curve for the developers working with our new framework.

One of the key benefits that we see ourselves gaining from using the new framework over Koin, including the resolution of the dependency miss-match issue with our merchants, is the opportunity to keep expanding the functionality of the framework in the future. This gives us a greater sense of freedom as we no longer have to work our solutions around the features and functionality of a third-party library. As a result of implementing our custom framework and removing the Koin dependency, we also experienced a notable decrease in bundle size by 244KiB, which translates to 63 fewer classes and 347 fewer methods.

As we continue to develop our SDK, our commitment extends to minimizing reliance on external dependencies whenever possible. Fortunately, within the Android ecosystem, all Android dependencies consistently maintain backward compatibility as emphasized in the AndroidX documentation. The sole non-Android dependency in our SDK, OkHttp, also guarantees backward binary compatibility, ensuring seamless integration and transition.

At Primer, we see every challenge as an opportunity, and every milestone reached as a stepping stone to helping us enable our merchants to do more. This change, while significant, is just one chapter in our journey toward even more remarkable user experiences.

--

--