Brief

The what: A quick snapshot of the context

You start with Clean Architecture:

ViewModel → UseCase → Repository

But after building a few features, you notice something strange:

class GetProductDetailsUseCase(
    val productRepository: ProductRepository
) {
    suspend operator fun invoke(id: String): ProductDetails? {
        return productRepository.getById(id)
    }
}

Half your UseCases contain one line. Just forwarding a Repository call.

You open Robert C. Martin’s book and find nothing about it. And that’s okay, because that book is just a marketable idea that put existing concepts together and named them. Not a production development guide.

You open Reddit for more practical discussions. And you’ll find threads like this:

Which now leaves you with three open questions, instead of one production-ready solution.

🐞 The Issue: One-line UseCases increase the amount of boilerplate and tests you need to maintain.

Make MVI your strength, not your weakness.

While 49% of devs hate MVVM and 49% hate MVI,

there are 2% who leverage it:

  • They understand the reasoning behind each line of code

  • They build architecture that survives any change without a refactor

  • They automate 90%+ of their work with AI

  • They constantly land promotions

76 Android devs already got the system I kept private for over 8 years.

( P.S. only 3 early access spots left )

Breakdown

The when: Reverse engineering the cause and the cost

Option 1 - No UseCases

This group of developers often refers to the application size.

“Don’t use UseCases for small applications.”

But no one can explain the definition of a “small” application.

Is this an app with 10 screens, under 100k lines of code, a certain number of developers working on it, or maybe the number of users or the revenue the company makes?

No clarity. Just developers’ feelings about whether it’s big or small.

After interviewing such devs, I found the definition:

“An app that only displays what the backend API returns.”

Even with such a clear definition, it’s still challenging to figure out where to use it or not.

I worked on an app with 1M+ users, 200+ screens, a team of 13 Android devs, no DB - just API calls. Is it a small app?

Pros & Cons:

  • ✅ Minimal code and abstractions.

  • ✅ Fast feature development for simple CRUD operations.

  • ✅ No need to create or maintain a separate layer and tests.

  • ❌ Business rules can become coupled to presentation logic.

  • ❌ Reusing operations across entry points may require duplication.

  • ❌ Moving logic out of ViewModels becomes a larger refactoring task as features grow.

Option 2 - Optional UseCases

This group of developers points to the over-engineering issue.

“Introduce UseCases for complex logic.”

But what exactly makes logic “complex”?

Is it calling two Repositories, calling other UseCases, adding an if statement, or transforming data?

No clear definition. Just developers deciding based on their own judgment.

Looking deeper into these discussions, I found the real reason behind it:

“If you need to reuse the logic elsewhere, extract it. Otherwise, keep it in the ViewModel.”

Logic doesn’t matter. The number of callers does.

But what do you do with a 100-line method in the ViewModel that has no other callers? The battle in code review will decide.

Pros & Cons:

  • ✅ Avoids one-line UseCases.

  • ✅ Shared business logic stays reusable.

  • ✅ Less boilerplate for simple CRUD operations.

  • ❌ No clear rule for introducing a UseCase.

  • ❌ Inconsistent architecture across features.

  • ❌ Recurring code review debates and refactoring.

Option 3 - Mandatory UseCases

This group of developers focuses on architecture consistency.

“Every ViewModel operation must go through a UseCase.”

No exceptions. Even if the UseCase only forwards a Repository call.

It solves the main problem of the previous two approaches:

No subjective decisions, no debates about complexity, and clear boundaries across the entire codebase.

But there's another problem: boilerplate.

In an app with 200+ screens, you end up with hundreds of one-line UseCases.

Each requires DI wiring, maintenance, and tests just to satisfy code coverage requirements.

Pros & Cons:

  • ✅ Consistent architecture across features and teams.

  • ✅ Clear boundaries between presentation, domain, and data logic.

  • ✅ Easier to enforce architecture rules in code reviews and AI-generated code.

  • ❌ Hundreds of one-line classes with no meaningful behavior.

  • ❌ Redundant tests to satisfy code coverage requirements.

  • ❌ More files for developers to navigate and maintain.

All three approaches focus on implementation details: how much code to write, where to put it, and which abstractions to use.

But production architecture must satisfy 3 business requirements:

1️⃣ Speed (time-to-market)

  • Faster feature development and A/B testing

  • Less time on refactoring and code reviews

  • Faster onboarding for new developers

2️⃣ Quality

  • High test coverage

  • Reliable, maintainable tests

  • Fewer regressions

3️⃣ Automation

  • More code generated by AI

  • Less manual development effort

  • Predictable output as AI usage scales

Each approach addresses some of these requirements, but none fully satisfies all three.

And choosing between boilerplate and architecture consistency shouldn't be the trade-off.

We need the benefits of all 3 approaches:

→ Keep the UseCase abstraction without writing useless implementations.

✅ The Fix: Replace one-line UseCases with SAM interfaces.

Guideline

The how: Implementation guideline and recommendations

The solution is surprisingly simple: Kotlin functional (SAM) interfaces.

Step 1 - Set up the contract

Where you had an interface for your UseCase, you need to either update its type to SAM or create a new fun interface.

fun interface GetProductUseCase {
    suspend operator fun invoke(id: String): Product?
}

🧠 Pro tip: if you want more concise syntax, you can update it to a lambda functional interface:

fun interface GetProductUseCase : suspend (String) -> Product?

Step 2 - Bind methods

Now, instead of a full UseCase implementation, you need to bind a repository method to the SAM interface.

@Module
@InstallIn(ViewModelComponent::class)
object UseCaseModule {

    @Provides
    fun provideGetProductUseCase(
       repository: ProductRepository
    ): GetProductUseCase = GetProductUseCase(repository::getById)
}

That's it. No implementation class.

Your ViewModel still depends on GetProductUseCase, not the Repository.

And you get:

  • ✅ Clear architecture boundaries without useless classes.

  • ✅ Consistent contracts that are easy to fake in tests.

  • ✅ No redundant implementation tests just to satisfy coverage.

When business logic actually appears, replace the binding with a real implementation.

The abstraction stays. Only its implementation changes.

Whenever you're ready, here’s how I can help you:

The exact system I used to build 11 apps, with 800+ screens, for 34M users. 10 years of production decisions and the reasoning behind every line, so your architecture survives any change without a refactor, and AI builds on it correctly.

Reply

Avatar

or to participate

Keep Reading