skills / android
mvvm
The shape of the layer graph: which layers exist, which way dependencies point, and what each one may touch. Use when the app has a ViewModel layer.
npx skyl.dev add android/mvvmInstalls android/core with it, because a layer that refers to its core reads wrong without it.
Rules
installedScope, priority, and when the whole skill does not apply
Where things go and what may reach what. android/core owns what state is what survives
process death, what the UI may claim, where a value is formatted. This owns the ** shape of the
graph**: which layers exist, which way they point, and what each one is allowed to touch.
The failure this prevents is not a wrong line of code. It is a screen that works, ships, and cannot be tested, reused, or changed without touching four files.
Scope. New code, and new features in an existing structure. Match the layering already in the module you are editing, a codebase with one consistent wrong shape is better than two right ones.
When a rule here conflicts with the code you are editing the surrounding convention wins for style and structure, but never for a rule whose failure loses user data, leaks a credential, or ships a crash. Fix those in their own change, not inside another one.
When not to apply(whole-skill): a single screen with no remote data and no persistence. A prototype. A sample.
Priority. must, the failure is structural and expensive to undo. should, real exceptions
exist; name yours.
The ViewModel
VM-2mustThe ViewModel holds no Android framework object: no Context, Activity Fragment, View, Resources, Uri resolution, or navigation controller.
Whyit outlives all of them, so holding one leaks it, and needing one is almost always a sign that a decision belongs in the UI or a string belongs in a resource.
Not whenthe application context, injected, for something that genuinely has no other home.
VM-3mustThe ViewModel decides what should happen; the UI decides how it looks. Navigation, dialogs, toasts and formatting are UI concerns triggered by state, not performed by the ViewModel.
Whya ViewModel that navigates cannot be tested without a nav host, and a ViewModel that formats has frozen a locale.
Not whennever, but "the UI decides how" includes choosing which string resource a state maps to.
VM-4shouldOne ViewModel per screen, scoped to that screen. Share one across screens only when they are genuinely one flow over one piece of state, a wizard, a multi-step form.
Whya ViewModel shared for convenience becomes a place to put anything, and its lifetime stops matching anything on screen.
Not whenthe flow really is one, and then scope it to the navigation graph, not to the activity.
The repository
REPO-1mustThe repository is the only thing that knows where data comes from. It owns the choice between network, cache and database, and callers cannot tell which answered.
Whythis is the single decision that makes offline support, caching and retry changeable in one place. A ViewModel that calls the API when online and the DAO when offline has taken that decision and spread it across every screen.
Not whenthere is exactly one source and no caching, then the repository is a thin pass-through and should be honest about it rather than growing ceremony.
REPO-2mustThe repository exposes domain types and domain failures. SDK exceptions, HTTP status codes and SQL errors stop there.
Whya status code reaching a ViewModel means the UI is deciding what 409 means.
Not whennever for errors that reach the user.
REPO-3shouldData sources are separate from the repository: one per origin, remote local, in-memory. The repository coordinates them and contains no I/O of its own.
Whyit is what lets you fake one source in a test while the other stays real, and it keeps the caching decision readable in one place rather than interleaved with parsing.
Not whena single source, where the split is two files pretending to be a design.
REPO-4mustA repository interface is defined where it is used not where it is implemented, and the ViewModel depends on the interface.
Whythis is the difference between a layered app and a layered folder structure. If the interface lives beside the implementation, the UI still depends on the data layer and the arrow in core BOUND-1 is decorative.
Not whenno test and no second implementation is plausible, say so. Or the codebase already declares its interfaces beside their implementations moving one interface across a module boundary is a change that reaches every call site, and it does not belong inside a feature. Write the new one correctly; move the old ones deliberately.
Why
The rule that carries this file is REPO-4 and it is the one people think they have already
followed. Almost every Android codebase has a Repository interface. Most of them declare it in
the same package as the implementation, inside the data layer, and at that point the UI still
depends on the data layer, the arrow in core BOUND-1 is decorative, and the layering is a folder
convention rather than a boundary. Moving the interface to where it is used is a one-line change
that converts one into the other.
Measured across two independent tasks, this is the thing a model does not do unprompted. It produces a repository, and it puts the interface next to the implementation.
Why data sources are separate from the repository. The repository's job is to decide: fetch or
serve from cache, write through or queue, which source wins on conflict. A source's job is to do:
one origin, no policy. Collapse them and the decision is interleaved with parsing and SQL, so you
cannot fake one origin in a test while the other stays real, and the caching rule stops being
readable in one place. The split earns its keep the moment there are two origins, and stops
earning it when there is one, which is why it is a should.
Why the ViewModel may not navigate. A ViewModel that calls a navigation controller cannot be tested without one, and it has taken a decision that belongs to whoever knows the current back stack. The state-based version, the ViewModel sets a field, the UI observes it and navigates, the UI clears it, is testable, survives configuration change, and puts the decision where the context is. The clearing step is the part people drop, and then the screen navigates again on every recomposition.
Why one ViewModel per screen, and what the exception really is. A ViewModel shared for convenience becomes the place anything goes, and its lifetime stops matching anything visible. The genuine exception is a flow that is one piece of state across several screens, a wizard, a checkout, a multi-step form, and even then it is scoped to the navigation graph rather than to the activity. "These two screens show related data" is not the exception; that is what a repository is for.
Why this file is small. Most of what is written about MVVM is either core's, dependency
direction, layer ownership, when to add a layer, or already what the model does. What is left is
where the repository boundary is drawn and what the ViewModel is allowed to touch.
Pitfalls
- A layered folder structure that is not a layered app. Every layer exists, and the repository
interface sits beside its implementation, so the UI still depends on
data. - Two screens that agree by coincidence. Each ViewModel calls the API and reads the store itself. It works until one copy diverges, and then the bug is in whichever screen you are not looking at.
- A repository you cannot test without a database. No separate sources, so faking the network means faking SQL too.
- A screen that navigates twice. The navigation event is state and nothing cleared it.
- A ViewModel that cannot be unit tested. It holds a
Context, aNavController, or formats a date. - A shared ViewModel that nothing owns. Scoped to the activity for convenience, now alive for the whole app and holding state for a screen that closed.
- An HTTP status code in a
wheninside the UI. The repository passed the transport error through instead of mapping it.
Provenance
Added later: the shared precedence sentence: when a rule here conflicts with the code you are editing, the surrounding convention wins for style and structure, but never for a rule whose failure loses user data, leaks a credential, or ships a crash. Those get their own change.
That line exists because android/java needed it and had to discover it: eval 11 scored LEAK-2 as
failing, and reading the runs showed two rules in the same file disagreeing, every treated run kept
a static Context because CONVERT-1 says preserve behaviour exactly, which was correct. Two models
arbitrated it without being told. The sentence writes down what they worked out, and it is reasoning
rather than measurement everywhere except java.
References
Depth a rule points at, loaded only when the agent asks for it.
The repository boundary
Referenced by mvvm REPO-3 and REPO-4.
Where the interface goes
This is the whole of REPO-4, and it is a one-line difference that decides whether the layering is
real.
// the boundary is decorative, ui still depends on data
data/
OrderRepository.kt interface
OrderRepositoryImpl.kt implementation
// the boundary is real, data depends on domain, ui depends on domain
domain/
OrderRepository.kt interface, declared where it is used
data/
OrderRepositoryImpl.kt implementation, depends on the interface above
The test: delete the data package. Does ui still compile? If yes, the arrow in
core BOUND-1 is load-bearing. If no, you have a folder convention.
In a single-module app the compiler will not enforce this, which is exactly why it needs to be a rule, nothing fails, and the coupling is invisible until someone tries to test the ViewModel or swap the source.
Repository decides, sources do
| Repository | Data source | |
|---|---|---|
| knows | that there are several origins | one origin |
| decides | cache vs network, write-through vs queue, who wins a conflict | nothing |
| contains | policy | I/O |
| in a test | real, with faked sources | faked, or real against an in-memory DB |
A repository containing a @GET call or a SQL string has absorbed a source. It still works, and
you can no longer fake the network without also faking the database.
One origin means no split. Two files where one would do is a design pretending to exist. REPO-3
is a should for this reason.
A shape that satisfies both
class SavedItemsRepository(
private val remote: ProductRemoteSource, // one origin, no policy
private val local: ProductLocalSource, // one origin, no policy
) : SavedItemsRepository { // interface declared in domain
fun products(): Flow<List<Product>> = local.observe()
suspend fun refresh(force: Boolean = false) {
if (!force && local.lastFetch().isFresherThan(1.hours)) return
local.replaceAll(remote.fetch()) // the policy lives here, in one place
}
}
Three things are true of it, and each is a rule:
- the interface is declared where the ViewModel is, not beside this class,
REPO-4 - the two origins are separate objects with no policy in them,
REPO-3 - staleness and forcing are one path with a flag, not two fetch methods,
REPO-1
The failure this prevents
Two screens, each with a ViewModel that calls the API when online and the DAO when offline. Both screens are correct in isolation. They agree because they happen to run the same logic, not because anything guarantees it, and the first time one is changed, they diverge, and the bug appears in the screen nobody edited.
That decision belongs in exactly one place. REPO-1 is what puts it there.
Evidence
The boundary between a state holder and the data behind it: what a ViewModel owns, and where a repository interface is declared.
What was run
2 evals, 30 recorded runs, on Haiku 4.5 and Sonnet 5. Every run is archived: the generated sources, the prompt each arm received, and the model each one reported.
Two different tasks.
What loading the skill changed
Declaring the repository interface where it is used rather than where it is implemented. This is the only result in the family that reproduced across two separate tasks, on both models.
Choosing between a local and a remote origin in one place, once a task supplied two origins for the same data.
That second one is why task design gets its own section in how these are run. The first eval reported it dead: that task had a single data origin, so there was nothing for the rule to act on.
What the tested models already handle
One rule was satisfied in every arm on both tasks and was dropped.
What the corpus said
The claim register called this skill a null: 31 high-worth claims, 5 evidenced, every one contested, and a summary saying it had no content of its own. The measurement disagreed.
Around this skill
Loads when a project has
file
- **/*ViewModel.kt
- **/*ViewModel.java
gradle dependency
- androidx.lifecycle:lifecycle-viewmodel