Android – App Architecture

September 13, 20268 min readUpdated 10/11/2026

Thirteen lessons have each looked at one part of this app. This one steps back and looks at the shape: which code lives where, which way the dependencies point, and what each layer is allowed to know. Architecture is not a diagram you draw first — it is the set of rules that keeps the hundredth feature as easy to add as the tenth.

Google publishes an official guide to Android app architecture, and this app follows most of it. Where it deliberately does not, this lesson says so and why.

The layers

feature/   screens, ViewModels, state holders        ─┐
app/       root composable, routes, sheets, toasts    │ depend on ↓
data/      Retrofit API + repository implementations  │
           ↓ implements                               │
domain/    models, cart rules, repository INTERFACES ◄┘  depends on nothing
core/      networking, storage, design system — knows nothing about pizza

The rule fits on one line: dependencies point inwards, toward the domain. A feature may use the domain; the domain uses nothing. And the one arrow that looks backwards — the data layer pointing up at the domain — is the most important line on the diagram.

The domain knows nothing

The domain package has no Android imports: no Context, no Compose, no Retrofit, no coroutine dispatchers. Models are plain data classes. The rules are plain functions. That is not purity for its own sake — it is what makes the most important code in the app runnable on the JVM in milliseconds, and readable by someone who has never touched Android.

Repositories, and the arrow that points up

A feature needs the menu. It should not know that the menu comes over HTTP, from which URL, through which library. So the domain declares what it needs as an interface:

interface CatalogRepository {
    suspend fun products(): List<Product>
    suspend fun toppings(): List<Topping>
    suspend fun crusts(): List<Crust>
}
interface OrderRepository {
    /**
     * Serves guests and signed-in customers alike. With a token the order is attached to the
     * account; without one, `guestEmail` is how the customer gets their receipt.
     */
    suspend fun createOrder(request: OrderCreateRequest): OrderCreateResponse

    /**
     * Asks the SERVER whether the payment settled. Deliberately not "mark this order paid" — the
     * device is never the authority on money, and anyone can call our API.
     */
    suspend fun paymentStatus(orderId: UuidString): Order
    suspend fun myOrders(page: Int, size: Int): Page<Order>
}

…and the data layer implements it:

class RemoteOrderRepository @Inject constructor(
    private val api: PizzaApi,
    private val call: ApiCaller,
) : OrderRepository {
    override suspend fun createOrder(request: OrderCreateRequest) = call { api.createOrder(request) }
    override suspend fun paymentStatus(orderId: UuidString) = call { api.paymentStatus(orderId) }
    override suspend fun myOrders(page: Int, size: Int) = call { api.myOrders(page, size) }
}

This is dependency inversion. The obvious design has the feature depending on the network code. Here both depend on an interface the domain owns, so the network code is the one that conforms. The payoff is that nothing under feature/ imports Retrofit or OkHttp. Swapping the transport for GraphQL, adding a Room cache, or handing a test three pizzas from a list touches data/ and nothing else.

Thin on purpose

The implementations are one line per method: a call in, a model out, through the error mapper. That thinness is a decision. A repository that also cached, retried and transformed would be a place for logic to accumulate where no test would look for it. Caching belongs in a store that owns state; retrying belongs to the caller who knows whether a retry is safe; error mapping already happened once, in ApiCaller.

Rules as a pure function

The cart is the most rule-dense part of the app — merging identical lines, removing a line at zero, keeping the order type when emptied — and four screens read it. Its rules live in a reducer: a function from the current state and an action to the next state.

data class CartState(
    val items: List<CartItem> = emptyList(),
    val orderType: OrderType = OrderType.DELIVERY,
) {
    val isEmpty: Boolean
        get() = items.isEmpty()
}
sealed interface CartAction {
    data class Add(val item: CartItem) : CartAction
    data class Remove(val lineId: String) : CartAction
    data class SetQuantity(val lineId: String, val quantity: Int) : CartAction
    data class SetOrderType(val orderType: OrderType) : CartAction

    /** Replace everything with what the server had saved. */
    data class Hydrate(val state: CartState) : CartAction
    data object Clear : CartAction
}
fun reduce(state: CartState, action: CartAction): CartState = when (action) {
    is CartAction.Add -> {
        val index = state.items.indexOfFirst { it.hasSameConfigurationAs(action.item) }
        if (index >= 0) {
            // Same configuration already in the cart: bump the quantity instead of adding a
            // second identical line, which is what a customer means by tapping "Add" twice.
            val existing = state.items[index]
            state.copy(
                items = state.items.toMutableList().apply {
                    set(index, existing.copy(quantity = existing.quantity + action.item.quantity))
                },
            )
        } else {
            state.copy(items = state.items + action.item)
        }
    }

    is CartAction.Remove ->
        // …

Three things follow from writing it this way. The rules are testable with no app at all — build a state, apply an action, assert on the result; the suite runs in milliseconds. Every mutation has a name, so when a cart ends up in an unexpected state there is a finite list of things that could have done it. And because CartAction is sealed, adding an action breaks the build until the reducer handles it.

The cost is indirection, and the source says so plainly: for four fields of form state a reducer would be ceremony. It earns its place where the state is shared and the rules are real. Pricing is the same idea without the actions — a pure object that turns lines into totals:

fun totals(items: List<CartItem>, orderType: OrderType): CartTotals {
    val subtotal = Money.rounded(items.sumOf(::lineTotal))

    // Pickup has no delivery fee, and neither does an empty cart — a $3.99 "total" for nothing
    // would be absurd, and it is the state the cart is in every time the app is first opened.
    val deliveryFee = if (orderType == OrderType.DELIVERY && subtotal > 0) DELIVERY_FEE else 0.0
    val tax = Money.rounded(subtotal * TAX_RATE)

    return CartTotals(
        subtotal = subtotal,
        tax = tax,
        deliveryFee = deliveryFee,
        total = Money.rounded(subtotal + tax + deliveryFee),
        itemCount = items.sumOf { it.quantity },
    )
}

A practical test for whether a new rule belongs here: if you can state it without mentioning a screen, a network or a thread, it is domain logic, and it goes in the domain.

Stores own effects

If the reducer owns what a change means, something has to own what a change causes: saving to the server, loading the saved cart at launch, flushing when the app is backgrounded. That is CartStore, and it is the only place the reducer is called:

fun setQuantity(lineId: String, quantity: Int) = dispatch(CartAction.SetQuantity(lineId, quantity))

fun setOrderType(orderType: OrderType) = dispatch(CartAction.SetOrderType(orderType))

/** Emptied after a successful payment. The order type survives, deliberately. */
fun clear() = dispatch(CartAction.Clear)

Every command funnels through one dispatch, which applies the reducer and schedules the save. A new command cannot forget to persist, because persisting is not something commands do.

ViewModels are doors

A composable cannot ask Hilt for a singleton store. A ViewModel can, and the screen gets the ViewModel — so for app-wide state, the ViewModel is a door and nothing more:

@HiltViewModel
class CartViewModel @Inject constructor(private val store: CartStore) : ViewModel() {
    val cart: StateFlow<CartState> = store.state
    val totals: StateFlow<CartTotals> = store.totals

    fun setQuantity(lineId: String, quantity: Int) = store.setQuantity(lineId, quantity)
    fun remove(lineId: String) = store.remove(lineId)
    fun setOrderType(orderType: OrderType) = store.setOrderType(orderType)
}

Where a screen has state and rules of its own — checkout's two steps, the receipt's polling, the profile's writes — the ViewModel holds them. Where it has none, it forwards. Both are fine; what is not fine is business rules in a composable, where only a UI test can reach them.

Unidirectional data flow

Put together, every interaction in the app travels the same loop:

tap "+"  →  CartSheet calls onQuantityChange(lineId, 3)
         →  CartViewModel.setQuantity  →  CartStore.dispatch(SetQuantity)
         →  CartReducer.reduce  →  _state.update(new CartState)
         →  StateFlow emits  →  collectAsStateWithLifecycle  →  CartSheet recomposes

State flows down; events flow up; there is exactly one place each piece of state can change. When something on screen is wrong, you can walk this loop backwards and find where. That is the whole value of unidirectional data flow, and it is why the app has no screen that writes to state it does not own.

Where this app departs from Google's guide

The official guide describes a UI layer (screens and ViewModels), an optional domain layer, and a data layer (repositories and data sources). This app maps onto it directly, with three deliberate differences.

No use-case classes. The guide's domain layer is often written as one class per operation — GetMenuUseCase, PlaceOrderUseCase — each with a single invoke. For an app with real cross-feature logic they are useful. Here they would mostly be one-line wrappers around a repository call, and every one would be a file to read that adds nothing. The domain layer here is the models, the interfaces and the cart rules, which is where the actual domain logic is.

No offline database. The guide favours a local database as the single source of truth, with the network syncing into it. This app keeps the cart on the server — that is a product requirement shared with the web apps — so the server is the source of truth and the device keeps only an id. A local cache would be a second copy to reconcile. When an app genuinely needs to work offline, Room behind the same repository interface is the next step, and no feature would change.

One module. Large apps split into Gradle modules — :core:network, :feature:checkout — so the build can compile them in parallel and the compiler can enforce the dependency rule (a feature module simply cannot import another). This app enforces it by convention, in packages, because a single module is easier to read in a tutorial. The package structure maps one-to-one onto modules, so splitting later is mechanical. The usual trigger is build time, or a second team.

What the iOS version does differently

Almost nothing structural: same four layers, same repository protocols in the domain, same pure reducer, same stores. The difference is how the graph is assembled — the SwiftUI app builds every dependency by hand in one composition root, and this one lets Hilt build it from annotations. That difference is big enough to be the next lesson.

Next

Dependency injection with Hilt: what it generates, modules and scopes, and how the same graph is rebuilt with fakes for a test — set against the hand-written version it replaces.