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.

On the importance of abstraction when using third-party libraries
Photo by Markus Winkler on Unsplash

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.

The code samples here will be written in Kotlin but the idea can be applied to any programming language and framework.

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.

Initial payment app architecture

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.

interface VendorSdk {

    enum class VendorPaymentStatus {
        SUCCESS,
        FAILED,
        CARD_DENIED
    }

    fun makePayment(amount: Int, resultCallback: (VendorPaymentStatus) -> Unit)

}

Third-party library's interface

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.

class FeatureA constructor(
    private val vendorSdk: VendorSdk,
    private val analyticsService: AnalyticsService
) {
    fun triggerCardPayment(amount: Int, paymentResultCallback: (isSuccessful: Boolean) -> Unit) {
        vendorSdk.makePayment(amount) { vendorPaymentStatus: VendorSdk.VendorPaymentStatus ->
            when (vendorPaymentStatus) {
                VendorSdk.VendorPaymentStatus.SUCCESS -> {
                    analyticsService.reportSuccessfulPayments()
                    paymentResultCallback(true)
                }
                else -> {
                    analyticsService.reportUnsuccessfulPayments()
                    paymentResultCallback(false)
                }
            }
        }
    }
}

How FeatureA might pass the payment result up to the UI of the app.

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.

App architecture with PaymentWrapper included

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.

interface PaymentWrapper {
    suspend fun pay(amount: Int) : PaymentResult

    enum class PaymentResult {
        SUCCESS,
        FAILED
    }
}

class VendorSdkPaymentWrapper constructor(
    private val vendorSdk: VendorSdk,
) : PaymentWrapper {
    override suspend fun pay(amount: Int): PaymentWrapper.PaymentResult {
        try {
            val completableDeferred = CompletableDeferred<PaymentWrapper.PaymentResult>()
            return withTimeout(5_000) {
                vendorSdk.makePayment(amount) { result ->
                    when (result) {
                        VendorSdk.VendorPaymentStatus.SUCCESS -> {
                            completableDeferred.complete(PaymentWrapper.PaymentResult.SUCCESS)
                        }
                        else -> {
                            completableDeferred.complete(PaymentWrapper.PaymentResult.FAILED)
                        }
                    }
                }
                completableDeferred.await()
            }
        } catch (e: Exception) {
            return PaymentWrapper.PaymentResult.FAILED
        }
    }
}

VendorSdkPaymentWrapper to make callbacks return the result instead of using callback

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):

class FeatureA constructor(
    private val paymentWrapper: PaymentWrapper,
    private val analyticsService: AnalyticsService,
    private val coroutineContext: CoroutineContext
) {
    suspend fun triggerCardPayment(amount: Int) : Boolean {
        val result = paymentWrapper.pay(amount)
        return when(result) {
            PaymentWrapper.PaymentResult.SUCCESS -> {
                analyticsService.reportSuccessfulPayment()
                true
            }
            else -> {
                analyticsService.reportUnsuccessfulPayment()
                false
            }
        }
    }
}

Refactored FeatureA using the PaymentWrapper

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?

Running UI tests on an emulator breaks when a third-party library depends on specific hardware

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.

Different implementation of PaymentWrapper through extension

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.

Inject the correct instance of PaymentWrapper according to use-case

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).

Dependency Injection in Flutter using Riverpod
Learn a simple method to apply dependency injection in Flutter using the popular state management framework Riverpod.

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 PaymentWrapperthat adheres to the interface of the new vendor SDK. Each feature's existing implementation and tests will be untouched because the interface never changes.

Migrate to a new library by creating a new instance of PaymentWrapper while the rest of the app remains unchanged.

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.