Deliver modern, mobile-first shopping experiences that delight your customers. Drive ROI back to your business across all customer touchpoints—in-store, online, and on mobile. Book a time with our team to learn how we can help transform your business with our omnichannel solutions.
*Privacy Notice: By hitting "Submit," I acknowledge receipt of the NewStore Privacy Policy.
In the day-to-day development of NewStore Consumer Apps, we try to strike a balance between adding new features and refining what we already have. We want to add new features because they generate value for our customers. We also want to make sure the apps, from code to user experience, are ready for the years to come. In this article, we share an example of how we recently improved our code best practices. We applied learnings, from the industry as a whole and those of our own, to existing code and set a standard for future additions to the apps.
We build consumer apps for iOS & Android and use native technology on both platforms. This article is about our Android codebase.
In our Android codebase, we’ve used dependency injection from the start with Dagger. This decision was made back when we wrote everything in Java, and our approach didn’t change much once we moved to Kotlin. Recently, we grew unhappy with the granularity of our dependencies and used Kotlin’s strengths to our advantage.
First, let’s look at the problem and our solution in the context of the order details of our consumer apps. On this screen, logged in users can see the details of their pending or previous orders.
The screen’s view model (VM) has a myriad of dependencies, but we’ll limit them to three important ones for this article: Order Repository for order information, ProductRepository for details on the products inside the orders and AccountManager for personalization of the view with the user’s info. In the way we’ve architected our VMs (we don’t use Jetpack ViewModels), we wrap these dependencies in a nested Dependencies type which is passed to the VM’s constructor:
class OrderHistoryDetailViewModel constructor(
override val dependencies: Dependencies,
override val model: Model,
override val bindings: Bindings,
val disposable: (Disposable) -> Unit
) {
/** VM implementation omitted */
class Dependencies @Inject constructor(
val orderRepository: OrderRepository,
val productRepository: ProductRepository,
val accountManager: AccountManager
)
}
The VM uses the following methods on its dependencies:
orderRepository.getOrderDetails(String): Observable<OrderDetails>
orderRepository.getCachedOrderDetails(String): OrderDetails?
productRepository.getDetailedProduct(Product.Id): Observable<DetailedProduct>
accountManager.getEffectiveAccount(): Observable<Account>
Note: the Disposable and Observable types come from RxJava.
The problem with this is that all three dependencies are large types as their public API contains several other methods not used by the VM. OrderHistoryRepository can return a collection of orders, ProductRepository provides access to product search results and more, and AccountManager can be used to log in and log out. None of these are the VM’s business.
The fact that the VM has access to the full public API of these dependencies violates the interface segregation principle and could increase coupling. Testing is also impacted. Creating full mocks of these dependencies is more work than needed. Additionally, using a partial mock instead requires careful consideration when selecting the methods to mock or risk undefined behavior.
The first thing we turned to as we tried to improve the relationship between the VM and its injected dependencies were interfaces. It’s considered good practice to inject interfaces instead of concrete types and we weren’t doing that here.
Replacing each concrete type with an interface with the exact same public API does not resolve the violation of the interface segregation principle. Instead, we can try to split up the responsibilities (or use cases – both a loaded term in this context) of each dependency and define separate interfaces, sometimes called a role interface. We could define OrderLists (defining methods related to lists or orders) and OrderDetails (defining the methods our VM needs) interfaces, with OrderHistoryRepository conforming to both. The VM would then expect a dependency of type OrderDetails, which Dagger would fulfill by proving a OrderHistoryRepository. And the same could be applied to ProductRepository and AccountManager.
With this approach, we quickly ran into cases where dependencies could not be broken up into separate interfaces that were universally applicable. Different VMs had different needs and ended up using APIs from multiple interfaces and only using part of each.
We resolved this by switching the “burden” of the interface definition from the repository to the view model. We introduced interfaces that defined exactly what the VM needed, as part of the Dependencies type. A nested Orders interface would define getOrderDetails(String) and getCachedOrderDetails(String), next to Products and Accounts interfaces with their respective methods:
// Nested in OrderHistoryDetailViewModel
class Dependencies @Inject constructor(
val orders: Orders,
val products: Products,
val accounts: Accounts
) {
interface Orders {
fun getOrderDetails(id: String): Observable<OrderDetails>
fun getCachedOrderDetails(id: String): OrderDetails?
}
/** Products & Accounts omitted */
}
// OrderRepository.kt
class OrderRepository(
/** constructor params omitted */
): OrderHistoryDetailViewModel.Dependencies.Orders {
/** main implementation omitted */
fun getOrderDetails(id: String): Observable<OrderDetails> {
return getOrFetchOrderDetails(id) // refers to a method that is depended on multiple times
}
fun getCachedOrderDetails(id: String): OrderDetails? {
return cache.get(id)
}
}
While this gives VMs the power to get exactly get what they need, it also causes a couple of new problems. The definition of OrderRepository now refers to a VM, by implementing an interface nested in the VM’s type. This violates the dependency inversion principle.
The second problem is that frequently-used dependencies end up with a large number of interfaces to implement, with clashing method definitions.
We resolved this by transforming the interfaces into a data type. The resulting type has a lambda property for each method that the interface defined:
class Orders(
val getOrderDetails: (String) -> Observable<OrderDetails>,
val getCachedOrderDetails: (String) -> OrderDetails?
)
Instead of having OrderRepository implement an interface, we can define how Orders can be instantiated with the repository:
class Orders(/** omitted */) {
constructor(repository: OrderRepository): this(
repository::getOrFetchOrderDetails,
{ repository.cache.get(it) }
)
}
It is important to note that the repository is not explicitly stored inside instances of Orders. The lambdas will be defined in terms of it and thereby restrict the interactions between the VM and the repository.
In the example above, we define the lambdas in two ways. The getOrderDetails lambda exactly matches the signature (and functionality) of a method on the repository, so we can use a function reference. The getCachedOrderDetails lambda has no identical match so we add some logic in-line. We prefer the first approach and try to avoid defining new logic in the implementation of this constructor.
Instances of Orders created with the OrderRepository constructor are called “witnesses” to the interface conformance. This is an idea we borrowed from our iOS codebase and the larger iOS community. Check out this talk by Brandon Williams at the App Builders conference in 2019 on the subject.
Putting all of this together, the VM’s dependencies will look like this:
class Dependencies @Inject constructor(
val orders: Orders,
val products: Products,
val effectiveAccount: () -> Observable<Account>
) {
@Inject constructor(
orderRepository: OrderRepository,
productRepository: ProductRepository,
accountManager: AccountManager
): this(
Orders(orderRepository),
Products(productRepository),
accountManager::getEffectiveAccount
)
class Orders(
val getOrderDetails: (String) -> Observable<OrderDetails>,
val getCachedOrderDetails: (String) -> OrderDetails?
) {
constructor(repository: OrderRepository): this(
repository::getOrFetchOrderDetails,
{ repository.cache.get(it) }
)
}
class Products(
val getDetailedProduct: (Product.Id) -> Observable<DetailedProduct>
) {
constructor(repository: ProductRepository): this(
repository::getDetailedProduct
)
}
}
Note that we ended up not defining a separate type for AccountManager. If there’s only one (or maybe two) method(s) that a VM depends on from another type, the corresponding lambda(s) could be defined directly on the Dependencies type.
When testing the VM, we want to provide it with an instance of Dependencies that does not use real versions of the dependencies. We use the primary constructors of Dependencies, Orders, and Products to create instances with lambdas that do not refer to the real repositories and account manager.
These test-specific lambdas can be set up as either mocks or fakes. Because lambdas behave much like other objects, they can also be mocked like objects. They are also very suitable to be faked, i.e. providing a dumbed down implementation that’s just complex enough for the test:
fun test() {
// arrange
val orders = mutableMapOf<String, OrderDetails>()
val products = mutableMapOf<String, OrderDetails>()
val effectiveAccount = BehaviorSubject.create<Account>()
val dependencies = OrderHistoryDetailViewModel.Dependencies(
OrderHistoryDetailViewModel.Dependencies.Orders(
{ orders[id]?.let { Observable.just(it) } ?: Observable.empty() },
{ orders[it] },
),
OrderHistoryDetailViewModel.Dependencies.Products(
{ products[id]?.let { Observable.just(it) } ?: Observable.empty() }
),
{ effectiveAccount }
)
val sut = OrderHistoryDetailViewModel(dependencies, /** other args omitted */)
/** act & assert steps omitted */
}
A variation of this can be achieved by creating a type that extends the lambda. The invoke method of the sub-type is called whenever the lambda is called. Inside that method, we can define the lambda’s behavior and more, like keeping track of how often the lambda has been invoked.
class FakeGetOrderDetails(
val orders: Map<String, OrderDetails>
): (String) -> Observable<OrderDetails> {
var invocationCount = 0
override fun invoke(id: String): Observable<OrderDetails> {
invocationCount++
return orders[id]?.let { Observable.just(it) } ?: Observable.empty()
}
}
For more tips on testing lambdas, see this post by Yang C.
In this post, we’ve shared the things we did to try to resolve a problem with our approach towards dependency injection. We often injected concrete types with a large public API surface, of which the client only needed a part. We’ve resolved this by replacing the direct dependencies on these large concrete types with small objects that have lambda properties. Instances of these small objects, also known as “witnesses,” are instantiated using the original dependencies, but only expose the exact functionality the client needs. This reduced coupling between view models and their dependencies and made them easier to test.