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.