Easily the most important and complex screen in the Buy on Etsy Android app is the listing screen, where all key information about an item for sale in the Etsy marketplace is displayed to buyers. Far from just a title and description, a price and a few images, over the years the listing screen has come to aggregate ratings and reviews, seller and shipping and stock information, and gained a variety of personalization and recommendation features. As information-rich as it is, as central as it is to the buying experience, for product teams the listing screen is an irresistible place to test out new methods and approaches. In just the last three years, apps teams have run nearly 200 experiments on it, often with multiple teams building and running experiments in parallel.
Eventually, with such a high velocity of experiment and code change, the listing screen started showing signs of stress. Its architecture was inconsistent and not meant to support a codebase expanding so much and so rapidly in size and complexity. Given the relative autonomy of Etsy app development teams, there ended up being a lot of reinventing the wheel, lots of incompatible patterns getting layered atop one another; in short the code resembled a giant plate of spaghetti. The main listing Fragment file alone had over 4000 lines of code in it!
Code that isn’t built for testability doesn’t test well, and test coverage for the listing screen was low. VERY low. Our legacy architecture made it hard for developers to add tests for business logic, and the tests that did get written were complex and brittle, and often caused continuous integration failures for seemingly unrelated changes. Developers would skip tests when it seemed too costly to write and maintain them, those skipped tests made the codebase harder for new developers to onboard into or work with confidently, and the result was a vicious circle that would lead to even less test coverage.
val title: Title,
val price: Price,
val saleEndingSoonBadge: SaleEndingSoonBadge,
val unitPricing: UnitPricing,
val vatTaxDescription: VatTaxDescription,
val transparentPricing: TransparentPricing,
val firstVariation: Variation,
val secondVariation: Variation,
val klarnaInfo: KlarnaInfo,
val freeShipping: FreeShipping,
val estimatedDelivery: EstimatedDelivery,
val quantity: Quantity,
val personalization: Personalization,
val expressCheckout: ExpressCheckout,
val cartButton: CartButton,
val termsAndConditions: TermsAndConditions,
val ineligibleShipping: IneligibleShipping,
val lottieNudge: LottieNudge,
val listingSignalColumns: ListingSignalColumns,
val shopBanner: ShopBanner,
) data class Title(
val text: String,
val textInAlternateLanguage: String? = null,
val isExpanded: Boolean = false,
) : ListingUiModel() In our older architecture, the screen was based on a single scrollable View. All data was bound and rendered during the View’s initial layout pass, which created a noticeable pause the first time the screen was loaded. In the new screen, a RecyclerView is backed by a ListAdapter, which allows for asynchronous diffs of the data changes, avoiding the need to rebind portions of the screen that aren’t receiving updates. Each of the vertical elements on the screen (title, image gallery, price, etc.) is represented by its own ViewHolder, which binds whichever of the smaller data models the element relies on. In this code, the BuyBox is transformed into a vertical list of ListingUiModels to display in the RecyclerView. fun BuyBox.toUiModels(): List<ListingUiModel> {
return listOf(
price,
title,
shopBanner,
listingSignalColumns,
unitPricing,
vatTaxDescription,
transparentPricing,
klarnaInfo,
estimatedDelivery,
firstVariation,
secondVariation,
quantity,
personalization,
ineligibleShipping,
cartButton,
expressCheckout,
termsAndConditions,
lottieNudge,
)
} An Event dispatching system handles user actions, which are represented by a sealed Event class. The use of sealed classes for Events, coupled with Kotlin “when” statements mapping Events to Handlers, provides compile-time safety to ensure all of the pieces are in place to handle the Event properly. These Events are fed to a single Dispatcher queue, which is responsible for routing Events to the Handlers that are registered to receive them. Handlers perform a variety of tasks: starting asynchronous network calls, dispatching more Events, dispatching SideEffects, or updating State. We want to make it easy to reason about what Handlers are doing, so our architecture promotes keeping their scope of responsibility as small as possible. Simple Handlers are simple to write tests for, which leads to better test coverage and improved developer confidence. In the example below, a click handler on the listing title sets a State property that tells the UI to display an expanded title: class TitleClickedHandler constructor() { fun handle(state: ListingViewState.Listing): ListingEventResult.StateChange {
val buyBox = state.buyBox
return ListingEventResult.StateChange(
state = state.copy(
buyBox = buyBox.copy(
title = title.copy(isExpanded = true)
)
)
)
}
} SideEffects are a special type of Event used to represent, typically, one-time operations that need to interact with the UI but aren’t considered pure business logic: showing dialogs, logging events, performing navigation or showing Snackbar messages. SideEffects end up being routed to the Fragment to be handled. Take the scenario of a user clicking on a listing’s Add to Cart button. The Handler for that Event might: dispatch a SideEffect to log the button click
start an asynchronous network call to update the user’s cart
update the State to show a loading indicator while the cart update finishes While the network call is running on a background thread, the Dispatcher is free to handle other Events that may be in the queue. When the network call completes in the background, a new Event will be dispatched with either a success or failure result. A different Handler is then responsible for handling both the success and failure Events. This diagram illustrates the flow of Events, SideEffects, and State through the architecture: Figure 1. A flow chart illustrating system components (blue boxes) and how events and state changes (yellow boxes) flow between them.
The ability of Handlers to dispatch their own Events sometimes makes debugging complex Handler interactions more difficult than previous formulations of the same business logic.
On a relatively simple screen, the architecture can feel like overkill.
Introducing Macramé
We decided that our new architecture for the listing screen, which we’ve named Macramé, would be based on immutable data propagated through a reactive UI. Reactive frameworks are widely deployed and well understood, and we could see a number of ways that reactivity would help us untangle the spaghetti. We chose to emulate architectures like Spotify’s Mobius, molded to fit the shape of Etsy’s codebase and its business requirements. At the core of the architecture is an immutable State object that represents our data model. State for the listing screen is passed to the UI as a single data object via a StateFlow instance; each time a piece of the data model changes the UI re-renders. Updates to State can be made either from a background thread or from the main UI thread, and using StateFlow ensures that all updates reach the main UI thread. When the data model for a screen is large, as it is for the listing screen, updating the UI from a single object makes things much simpler to test and reason about than if multiple separate models are making changes independently. And that simplicity lets us streamline the rest of the architecture. When changes are made to the State, the monolithic data model gets transformed into a list of smaller models that represent what will actually be shown to the user, in vertical order on the screen. The code below shows an example of state held in the Buy Box section of the screen, along with its smaller Title sub-component. data class BuyBox(val title: Title,
val price: Price,
val saleEndingSoonBadge: SaleEndingSoonBadge,
val unitPricing: UnitPricing,
val vatTaxDescription: VatTaxDescription,
val transparentPricing: TransparentPricing,
val firstVariation: Variation,
val secondVariation: Variation,
val klarnaInfo: KlarnaInfo,
val freeShipping: FreeShipping,
val estimatedDelivery: EstimatedDelivery,
val quantity: Quantity,
val personalization: Personalization,
val expressCheckout: ExpressCheckout,
val cartButton: CartButton,
val termsAndConditions: TermsAndConditions,
val ineligibleShipping: IneligibleShipping,
val lottieNudge: LottieNudge,
val listingSignalColumns: ListingSignalColumns,
val shopBanner: ShopBanner,
) data class Title(
val text: String,
val textInAlternateLanguage: String? = null,
val isExpanded: Boolean = false,
) : ListingUiModel() In our older architecture, the screen was based on a single scrollable View. All data was bound and rendered during the View’s initial layout pass, which created a noticeable pause the first time the screen was loaded. In the new screen, a RecyclerView is backed by a ListAdapter, which allows for asynchronous diffs of the data changes, avoiding the need to rebind portions of the screen that aren’t receiving updates. Each of the vertical elements on the screen (title, image gallery, price, etc.) is represented by its own ViewHolder, which binds whichever of the smaller data models the element relies on. In this code, the BuyBox is transformed into a vertical list of ListingUiModels to display in the RecyclerView. fun BuyBox.toUiModels(): List<ListingUiModel> {
return listOf(
price,
title,
shopBanner,
listingSignalColumns,
unitPricing,
vatTaxDescription,
transparentPricing,
klarnaInfo,
estimatedDelivery,
firstVariation,
secondVariation,
quantity,
personalization,
ineligibleShipping,
cartButton,
expressCheckout,
termsAndConditions,
lottieNudge,
)
} An Event dispatching system handles user actions, which are represented by a sealed Event class. The use of sealed classes for Events, coupled with Kotlin “when” statements mapping Events to Handlers, provides compile-time safety to ensure all of the pieces are in place to handle the Event properly. These Events are fed to a single Dispatcher queue, which is responsible for routing Events to the Handlers that are registered to receive them. Handlers perform a variety of tasks: starting asynchronous network calls, dispatching more Events, dispatching SideEffects, or updating State. We want to make it easy to reason about what Handlers are doing, so our architecture promotes keeping their scope of responsibility as small as possible. Simple Handlers are simple to write tests for, which leads to better test coverage and improved developer confidence. In the example below, a click handler on the listing title sets a State property that tells the UI to display an expanded title: class TitleClickedHandler constructor() { fun handle(state: ListingViewState.Listing): ListingEventResult.StateChange {
val buyBox = state.buyBox
return ListingEventResult.StateChange(
state = state.copy(
buyBox = buyBox.copy(
title = title.copy(isExpanded = true)
)
)
)
}
} SideEffects are a special type of Event used to represent, typically, one-time operations that need to interact with the UI but aren’t considered pure business logic: showing dialogs, logging events, performing navigation or showing Snackbar messages. SideEffects end up being routed to the Fragment to be handled. Take the scenario of a user clicking on a listing’s Add to Cart button. The Handler for that Event might: dispatch a SideEffect to log the button click
start an asynchronous network call to update the user’s cart
update the State to show a loading indicator while the cart update finishes While the network call is running on a background thread, the Dispatcher is free to handle other Events that may be in the queue. When the network call completes in the background, a new Event will be dispatched with either a success or failure result. A different Handler is then responsible for handling both the success and failure Events. This diagram illustrates the flow of Events, SideEffects, and State through the architecture: Figure 1. A flow chart illustrating system components (blue boxes) and how events and state changes (yellow boxes) flow between them.
Results
The rewrite process took five months, with as many as five Android developers working on the project at once. One challenge we faced along the way was keeping the new listing screen up to date with all of the experiments being run on the old listing screen while development was in progress. The team also had to create a suite of tests that could comprehensively cover the diversity of listings available on Etsy, to ensure that we didn’t forget any features or break any. With the rewrite complete, the team ran an A/B experiment against the existing listing screen to test both performance and user behavior between the two versions. Though the new listing screen felt qualitatively quicker than the old listing screen, we wanted to understand how users would react to subtle changes in the new experience. We instrumented both the old and the new listing screens to measure performance changes from the refactor. The new screen performed even better than expected. Time to First Content was decreased by 18%, going from 1585 ms down to 1298 ms. This speedup resulted in the average number of listings viewed by buyers increasing 2.4%, add to carts increasing 0.43%, searches increasing by 2%, and buyer review photo views increasing by 3.3%. On the developer side, unit test coverage increased from single digit percentages to a whopping 76% code coverage of business logic classes. This significantly validates our decision to put nearly all business logic into Handler classes, each responsible for handling just a single Event at a time. We built a robust collection of tools for generating testing States in a variety of common configurations, so writing unit tests for the Handlers is as simple as generating an input event and validating that the correct State and SideEffects are produced. Creating any new architecture involves making tradeoffs, and this project was no exception. Macramé is under active development, and we have a few pieces of feedback on our agenda to be addressed: There is some amount of boilerplate still needed to correctly wire up a new Event and Handler, and we’d like to make that go away.The ability of Handlers to dispatch their own Events sometimes makes debugging complex Handler interactions more difficult than previous formulations of the same business logic.
On a relatively simple screen, the architecture can feel like overkill.
Adding new features correctly to the listing screen is now the easy thing to do. The dual benefit of increasing business metrics while also increasing developer productivity and satisfaction has resulted in the Android team expanding the usage of Macramé to two more of the key screens in the app (Cart and Shop), both of which completely rewrote their UI using Jetpack Compose: but those are topics for future Code as Craft posts.