On the importance of abstraction when using third-party libraries
Learn the importance of abstraction when calling third-party libraries to keep your code testable, scalable, and resilient to future changes.
A good rule of thumb when using a third-party library is to use wrapper functions to abstract the implementation instead of calling their APIs directly. Doing so makes your code easily testable and flexible whenever you decide to use a different library.
This applies to third-party libraries that use databases, networking, hardware sensor events, and more.
I will share some insights on how abstraction allows us to move faster in developing new features while keeping our code testable. Imagine we are tasked to make a feature-rich Android app for taking card payments. This app will depend on a particular vendor that builds the device and provides us with an SDK (Software Development Kit) along with it.
Directly using a third-party library influences the app's architecture
The quickest way to use a library is by calling its APIs directly. When building an app, it is straightforward to adapt to what the SDK interface gives you.
Suppose the VendorSdk
has an API for payment like this.
In this example, the vendor SDK provides a makePayment
function that accepts an amount
and informs the caller of the payment result through the resultCallback
function argument.
If I implement a payment flow within FeatureA
, it would look something like this.
This implementation looks fine. The FeatureA
module has access to VendorSdk
, which in turn has access to the device's hardware for making payments. But notice how FeatureA
has to adapt VendorSdk
's interface which in this case is a callback function.
There is nothing wrong with defining the triggerCardPayment()
method in FeatureA
to accept a callback function. The problem is that we were somehow forced to include paymentListener
because the SDK requires one. That is the only way we can handle the payment result of the VendorSdk
.
This might pose a problem because our entire app depends on how to use a specific vendor SDK and how it defines a particular way of getting the result. What if we decide to move to another vendor? Changing all the feature blocks to adhere to a new interface is very time-consuming. The ideal approach when migrating to another vendor's SDK is only to change a single point in our app.
Create a wrapper class to abstract a third-party library's API
A wrapper class (or function) in programming is a container whose purpose is to call another function. It might seem redundant, but this approach introduces a few advantages to improve our code.
With this approach, we can decide how we pass and receive data between the app and the VendorSDK
. This extra layer will transform data and define the interface the app expects from third-party libraries.
In rare instances when a particular third-party library has been deprecated, moving to another library will only involve changing the Wrapper
container whilst keeping the features of our app the same. This is in line with the principle of coding against interfaces, not implementations where the PaymentWrapper
is such an interface to one or more different third-party libraries.
Returning to our example, we might implement a PaymentWrapper
that looks like this.
As a team decision, we may have agreed to have our payment interface return the payment result from the function call instead of through a callback function. We can transform this approach in the VendorSdkPaymentWrapper
.
The FeatureA
module can adapt to this interface without knowing how the vendor implements their payment flow. We could potentially write the FeatureA
as follows where the triggerCardPayment()
can be run on a separate coroutine (asynchronously):
In this particular example, this is a better approach than using a callback function because there is a chance that the callback function may never be called, rendering your app to be stuck if there are no safeguards for timeouts.
The important thing here is that the PaymentWrapper
interface is dependent on your (or your team's) decision and not decided by the third-party library that you're using.
Now that there's an abstraction on how we make payments, our code will be easier to change if the vendor SDK changes its implementation.
Running automated tests with third-party libraries
For the most part, running automated tests on an app that includes third-party libraries on emulators or simulators works fine as long as these libraries depend on common platform APIs such as Android or iOS frameworks. But what happens if a library is hardware-dependent?
If we want to run automation tests on an emulated device when a particular SDK only works on real devices, then using the third-party library directly will not work. In our payment app, these automation tests would fail as emulators do not have the necessary hardware for payment.
The good thing is that with the PaymentWrapper
we wrote, it is easier to make testing possible by changing a few bits in our implementation.
Use interfaces or abstract classes when writing wrapper classes
Use the abstract or interface construct when writing your wrapper functions or classes (as long as your framework supports it). This approach makes it easier to swap implementation whenever needed. Since we have already written PaymentWrapper
as an interface
, we can simply write up new implementations according to the services we need.
Our app will have two instances of PaymentWrappers
, one for running the real app for production and the other for running UI tests on emulators.
class UiTestPaymentWrapper : PaymentWrapper {
override suspend fun pay(amount: Int): PaymentWrapper.PaymentResult {
return PaymentWrapper.PaymentResult.SUCCESS
}
}
The implementation of UiTestPaymentWrapper
does not depend on the VendorSdk
, so we are sure that this will run on UI tests. In this example, calling the pay()
method always returns a successful result. To change this result, you can introduce a setter()
function and change the return value according to different test cases.
Swapping implementations of PaymentWrapper
when building the app depends on how you implement dependency management, and how these dependencies are injected into your wrappers. (For Android, Hilt
is a popular dependency injection library).
As mentioned before, this approach also solves the problem of migrating from one vendor to another. Instead of rewriting everything, we only need to add another implementation of PaymentWrapper
that adheres to the interface of the new vendor SDK. Each feature's existing implementation and tests will be untouched because the interface never changes.
Conclusion
Abstraction of third-party libraries can make your app more scalable, testable, and flexible when it comes to changes. Using wrapper classes as an abstraction can keep your features unchanged regardless of how a third-party library is implemented under the hood. This extra layer may seem like additional work, but the benefits of being resilient to dependency changes and testable code far outweigh the time cost.