Airtable Interfaces Meet Android: Inside the Architecture
“Good Android apps are like onions — they have layers.”
— Shrek, if he was an Android Developer, probably.
In late 2021, Airtable launched Interfaces in the browser, a feature that allows users to build interactive user interfaces on top of Airtable’s existing data and automation offerings. Having quickly gained popularity amongst our web users, Interfaces on mobile became a gap we sought to fill. This is the journey of bringing Airtable Interfaces to Android.
Breaking apart a 10 year old Android codebase
Older codebases tend to accumulate debt and legacy patterns as they grow; to set the scene, we had a decade-old codebase with a lot of Java, numerous activities, hundreds of XML layouts, and extensive use of EventBus for state management. Components relied on listening to EventBus events, which made tracking down cause and effect tricky as the app grew. Realizing we needed to clear a path for new development, we knew we needed to make a plan on how to move forward.
Instead of jumping straight into building Interfaces, we decided to first refactor our existing code to make it more scalable. While our architecture had gotten us this far, we recognized that to support rapid iteration for a new, large-scale product, we needed to modernize our approach. This way, we’d make future development smoother and easier to maintain.
Our goal in refactoring the app was to break free from legacy patterns and start future development with a clean slate. As you’ll see, the key was designing a clear layer around our crown jewel: data.
Our app, like many others, can be conceptualized in four main layers: the UI, business logic, data, and network. Our legacy architecture was heavily knotted — better visualized as a gradient than layers. We had a mountainous challenge and three engineers. How were we to break apart our monolith?
It began with Dependency Injection (DI). We transitioned from a sparsely adopted use of Dagger to adopting Hilt across the board — a pivotal foundational change that set the stage for future development. DI allowed us to begin teasing out layers, scoping their lifecycles, and re-injecting them back into the monolith. While this may sound futile, it began to create contracts between components, making the boundaries between layers jagged but clear, as visualized in Fig 1(b). Perhaps some logic was in the wrong layer, but at least it was defined contractually.
Leveraging DI, we continued our restructuring from the middle-out, starting with the data layer. Tackling this from the middle allowed us to address two layer boundaries at once. By selectively exposing a well-structured, intentional API for our data layer, the network and business logic layers naturally had to conform. Furthermore, by focusing on the data layer, we aligned our app’s source of truth; whatever is in the repository must be correct.
@Singleton
class SomethingRepository @Inject constructor(
private val coreRepository: CoreSomethingRepository,
) {
private val modelById: MutableStateFlow<Map<ModelId, Model>> = MutableStateFlow(emptyMap())
fun putSomething(id: ModelId, model: Model) = ...
fun streamSomethingById(id: ModelId): Flow<Model> = ...
}
To create a data layer with an API that enforces a strict boundary, we adopted the repository pattern, exposing streams of data for the business logic to retrieve the latest state. We found this pattern particularly effective, as state is streamed rather than events, allowing consumers to ignore how the data was changed and focus on the result. While this may not be a new concept, in the context of all the EventBus usage we had, having a single source of truth was an extremely valuable change.
This brings us to Fig 1(c), the state of the app before beginning work on Interfaces, a far cry compared to where we began, and a reasonable starting point for developing a brand new facet of our Android App. With a cleaner, more modular codebase in place, we were poised to make a pivotal architectural decision that would significantly impact the development of Interfaces on Android. In the next section, we’ll explore how we leveraged our now consistent data layer to implement Interfaces.
A tale of two architectures
Having prepared the app for growth, we faced a fork in the road — do we double down and use our old architecture to build Interfaces, or adopt more modern standards for implementing such a large feature? As Jeff Bezos would describe it, this was a type 1 decision — an irreversible decision that would have long-term consequences. We evaluated the tradeoffs of each strategy, and opted for a new architecture with the following considerations.
Our biggest concern was with development velocity; we acknowledged the overhead of setting up a new architecture properly, but we had conviction that the future gains in development speed would be worth it. Jetpack Compose, for example, is much more concise and easier to maintain than XML; Compose’s declarative approach enables reactive UI updates, aligning with modern development practices and improving development velocity.
Another consideration was in reliability. As Android developers know, the system is usually not our friend; activities, fragments, bundles and lifecycles make development complicated and error prone. A single activity architecture would reduce the coupling between our app and the Android framework, giving us greater control over navigation and state.
Lastly, with a fresh new architecture, it would be possible to begin incremental improvements to our old architecture, rather than be stuck in legacy patterns, we could gradually make headway in refreshing the entire app. Also, to be frank, hiring engineers would be easier too — who wants to work in a crusty old codebase?
At a glance, we chose the following core libraries to fill out our stack:
- Ktor for networking,
- KotlinX for serialization,
- Kotlin Flows for reactive data,
- ViewModels for business logic, and
- Compose & Material 3 for UI.
While we could discuss library choice all day, for the sake of brevity, we found this combination to have good interoperability and overall support.
With the architectural groundwork laid, it became clear that to fully realize the benefits of our new system, we needed to build solid foundations for Android Interfaces to enable consistent development standards. To best enable our engineering team, we needed to understand how the data was structured.
The data behind Airtable Interfaces
There are two primary sources of data behind Interfaces: the layout data and the application data. These work together to structure the layout (with layout data) and populate the layout (with application data), in this article, we’ll primarily focus on how the layout works, as the application data is less relevant to this architecture discussion.
The layout data behind Interfaces is effectively Server Driven User Interfaces (SDUI). The layout data is a large hierarchy of parent-child relationships to eventually build a working UI that can be interacted with. Without going into too much detail, the layout information can be conceptualized as a Directed Acyclic Graph or DAG (you can think of this as a tree where nodes can have multiple parents).
"layoutNodes": {
"id0": {"child": "id1"...},
"id1": {...},
}This lended well to our repository pattern, elements could be stored easily in a map, and streamed in case there were any changes to the layout while viewing it. However, with such a broad mandate to support arbitrary layouts, we needed to adopt practices that would safeguard our codebase against misuse and future errors. As you can imagine, the layout DAG can take innumerable forms and our code had to be flexible enough to handle it. This brings us to the concept of building a defensive architecture.
Defensive architecture
It all starts with an ID.
Even the most skilled engineers may occasionally write code that doesn’t fully align with the architectural patterns. This isn’t necessarily their fault; sometimes the broader vision isn’t clearly communicated, leading to decisions that diverge from pre-existing patterns. It’s tempting to call code directly across boundaries or pass data directly to a child component. While these actions might seem harmless or even more efficient at first, software development — like driving — is a collaborative activity, and shortcuts can gradually cause the system to break down.
We use the term Defensive Architecture to “defend” against antipatterns and to encourage best practices in our codebase. We’ll dive into an example of how one of our layout APIs is designed; this is just the tip of the iceberg of the many ways the framework is structured to encourage clean code.
Consider the following example where parent-child components manage state independently. At a glance, it doesn’t look too bad right? We take in a state and mutate it internally, why would another component care if the internal state changes? If you already know why this is error-prone, feel free to skip to the next section.
@Composable
fun ParentComponent(initialState: ParentState) {
val state by mutableStateOf(initialState)
Column {
Text(state.childState.name)
ChildComponent(state.childState)
}
}
@Composable
fun ChildComponent(initialState: ChildState) {
var state by mutableStateOf(initialState)
LaunchedEffect(Unit) {
state = state.copy(name = "new name")
}
Text(state.name)
}
If we look carefully in this scenario, ChildState is both passed into and managed by ChildComponent, causing the source of truth to diverge. In this example, the Text in ParentComponent will not update, even though the state is updated within ChildComponent. While this example is fairly canned, it serves to show how a lack of structure can easily lead to tight coupling.
To defend against tight coupling, conceptually we established two main types of components:
- Business Logic Components: These are self-contained, top-level entities with minimal inputs. They are responsible for creating and managing their own state, as well as the state of their children. For any of its descendants that are also business logic components, the parent has no responsibility for the child’s state, and delegates responsibility entirely to the child. We’ll see how this is done below.
Contextualized with the layout data, each instance of a business logic component corresponds to a node in the DAG. - Pure UI Components: These consume state and return events to business logic components, similar to components found in UI libraries like Material Design. These components are mostly around to share common UI code, and break down the size of Business Logic Components.
To defend against business logic components from being tightly coupled with one another, we designed the API to prevent it from happening. Below is the actual composable function we use to unify our layout system, and protect against improper use. This single composable inflates almost any business logic component in our new architecture. Its generality helps prevent misuse: from a caller’s standpoint, it’s straightforward — knowing the ID will inflate the required component and one cannot pass state in. From an implementer’s standpoint, it serves as a guide by requiring data to be retrieved from the data layer rather than passed around from less reliable sources.
@Composable
fun LayoutNode(
layoutNodeId: LayoutNodeId,
modifier: Modifier = Modifier,
)
Let’s revisit the earlier example, but now using a defensive architecture. By requiring each component to be inflated by ID, we enforce a pattern where each component independently retrieves its own data and manages its state, rather than relying on data passed down from parent components.
Here’s how the code reflects this architecture:
@Composable
fun ParentComponent(id: ParentNodeId) {
val viewModel: ParentComponentViewModel = viewModel()
val state = viewModel.streamState(id).collectAsStateWithLifecycle()
Column {
Text(state.childName)
ChildComponent(state.childId)
}
}
@Composable
fun ChildComponent(id: ChildNodeId) {
val viewModel: ChildComponentViewModel = viewModel()
val state = viewModel.streamState(id).collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.setName(id, "new name") // ParentComponent is notified through the data layer that something has changed
}
Text(state.name)
}
In this improved version, we note the benefits we’ve gained from decoupling. Each component is self-contained and modular, and we prevent data inconsistencies or stale data by reading directly from the data layer. Additionally, we gain the benefit of easier maintenance — changes in one component are less likely to impact others (reducing merge conflicts), simplifying debugging, and reducing ambiguity in future development.
To reiterate, this is only one of many ways to encourage best engineering practices — by considering the impact of the API early on, we’re able to encourage robust architecture that reinforces a clean separation of concerns, and promotes a more maintainable and reliable codebase.
Putting it all together
So far, we’ve delved into various facets of how our app works, but if you’ve read this far, you’re probably wondering, how does this all fit together? Fig 3 illustrates the high level view of our architecture, and should be reminiscent of many of the topics we covered earlier. Screens and nodes in the layout are represented by business logic components that interact with the data layer to retrieve the source of truth; Pure UI components are simply descendants of business logic components, and do not manage their own state.
A clear distinction between layers is particularly important in our application architecture. By structuring our app into well-defined layers, we establish explicit contracts between them. This allows each layer to function internally in its own way while exposing an API to other layers that specifies what it can provide.
This design makes it easy to test each layer independently because we focus solely on the contracts, not the internal workings of other layers. For instance, when testing the data layer, we can examine its contract with the business logic and network layers by asking questions like, “What should the data layer emit when the network provides new data?” This approach isolates the data layer’s functionality, simplifying the testing process.
In contrast, a tightly coupled system lacks clear boundaries between layers, making testing more complex. We might find ourselves asking, “What text should be shown when the network provides new data?” This question involves multiple layers — data processing, business logic, and user interface — introducing more dependencies and levels of indirection. Testing in such a system requires understanding and coordinating across all these layers, which can be cumbersome and error-prone. By testing components in each layer separately, we shrink the size of tests, and can find the root cause faster.
To finish where we started, just remember: Good Android apps are like onions — they have layers. By carefully deconstructing our monolithic codebase into well-defined, modular layers, we were able to bring Airtable Interfaces to Android with a solid architectural foundation. This layered approach not only enhanced our development velocity and reliability, but also set the stage for future scalability and maintainability. Through defensive architecture and clear separation of concerns, each component became robust, testable, and easier to reason about. Our journey underscores the importance of investing time in architecture and embracing modern development practices. In the end, it’s all about the layers.
Airtable Interfaces Meet Android was originally published in The Airtable Engineering Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.