| name | no-use-case-layer-decision |
| description | Decide whether a mid-size app needs an interactor or use-case tier at all, how a clean boundary survives without one (repository interfaces plus pure mapping functions), how to prove an absence rather than assume it, and the specific signal that says it is finally time to add the tier back. Use when the tier feels like typing with no payoff, when reviewing an architecture that has none, or when the same orchestration has been pasted into a third view model. |
Shipping without a use-case tier
The textbook stack puts an interactor between the presentation layer and the repository. A
mid-size, feature-broad app can ship with zero of them and still have a boundary worth the
name, because the boundary was never the interactor — it was the interface.
What holds it up instead:
Proving an absence
"There are no use cases" is a claim that decays silently, so make it a command. A use-case tier
hides under at least five spellings, and one grep misses four of them:
grep -rn "class .*UseCase\|interface .*UseCase\|class .*Interactor" --include="*.kt" .
find . -name "*UseCase*.kt" -o -name "*Interactor*.kt"
grep -rn "^package .*\.\(usecase\|usecases\|interactor\|interactors\)$" --include="*.kt" .
grep -rn "operator fun invoke" --include="*.kt" .
find . -type d \( -name usecase -o -name usecases -o -name interactor \)
Exclude generated output (--exclude-dir=build) or the results are noise. All five returning
nothing is the evidence; anything else means the tier exists and the real question is whether it is
consistent. Put this in a review checklist rather than a comment — a comment claiming an absence
ages badly, and a comment is not evidence for a structural property.
What the tier would have bought
Be specific about what is being given up, because it is not nothing:
- Composition across repositories. A flow that reads two repositories and merges them has no
natural owner and lands in the view model.
- A test seam without the presentation framework. Orchestration tested through a use case needs
no view model harness, no main-thread dispatcher rule, no lifecycle owner.
- A name for the operation.
AddSongToPlaylist is greppable in a way that "the block inside
fun onAddClicked" is not.
- Reuse across presentation surfaces. A widget, a background job and a screen wanting the same
operation each re-implement it otherwise.
The tell for adding it back
The same orchestration pasted into a third view model. Two is a coincidence and a shared private
function is enough; three is a structural signal that the operation exists independently of any
screen. Reintroduce it for that operation only — a single class, injected where the repositories
were, returning the same envelope type. A tier introduced wholesale, one wrapper per repository
method, buys none of the four benefits above and doubles the call chain.
The second, weaker tell: a background component (a widget, a scheduled job, a notification handler)
needs an operation that currently lives in a view model. Rather than injecting a view model into it
— see koin-viewmodel-scoping-traps for why that goes wrong — lift just that operation out.
Traps
Dropping the tier does not license logic in the view model — it relocates the ceiling. Without
the tier the session-scoped view model is the one that absorbs orchestration, and it grows without
any single change looking unreasonable. Watch it by size, not by feel:
APP_SRC=composeApp/src
find "$APP_SRC" -path '*/viewModel/*.kt' -exec wc -l {} + | sort -rn | head
When one file is an order of magnitude larger than its siblings, the tier came back — unnamed,
untestable and bound to the presentation lifecycle.
"No use cases" is not "no domain layer". The domain module still owns the interfaces, the
models and the capability ports. An architecture with repository implementations injected directly
into view models has no boundary at all, and it looks identical in a folder listing.
Do not let a repository return transport types to skip a mapper. The moment
fun search(q: String): Flow<ServiceSearchResponse> exists, every consumer imports the service's
model and the missing tier stops being the problem. The mapping functions are the load-bearing
part; the interactor was optional.
The pure-function alternative is only pure if it stays a function. A mapper that starts taking
a repository, a dispatcher or a clock has become a use case wearing a fun keyword — with none of
the discoverability. At that point name it and inject it.
A "clean" review still has to check the direction of dependencies, not the presence of folders.
Run the grep from clean-arch-kmp-readiness that lists what the app module imports out of the data
module. A stack with no use cases and no leaks is clean; a stack with a full interactor tier and a
repository implementation imported into a screen is not.
Verifying it
## Proving an absence, above, is this skill's main check — run it before trusting anything else
here. These three cover the claims that hold the boundary up in its place:
-
No view model imports a RepositoryImpl, only the interface:
VM_DIR=composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel
grep -rln "RepositoryImpl" --include="*.kt" "$VM_DIR"
Pass condition: no output. A hit means a screen is constructing (or naming) the implementation directly, and the interface boundary is decorative for that one class.
-
The pure mapping functions this skill leans on for "where did the use case's logic go" exist,
and stay internal:
grep -rn "internal fun .*\.to[A-Z][A-Za-z]*(" --include="*.kt" core/data/src | wc -l
Pass condition: nonzero — here, 19 — and each one only reachable from inside the data module. A
public one is a mapper the app module could call directly, skipping the repository.
-
No repository interface imports an integration's transport types — checked by inverting the whitelist instead of naming one:
DOMAIN_REPO=core/domain/src/commonMain/kotlin/com/maxrave/domain/repository
grep -rh "^import " "$DOMAIN_REPO"/*.kt | grep -v "^import com.maxrave.domain\|^import kotlin" | sort -u
Pass condition: nothing printed belongs to an integration module — here it prints one line, import androidx.paging.PagingData. A hit is the "do not let a repository return transport types" trap, caught structurally instead of in review.