Patterning Dagger/Hilt Cases Where a Module Is or Is Not Required

 

🧑🏻‍💻 When a Module Is Not Required: Concrete Classes

In Hilt/Dagger, concrete classes with an @Inject constructor can be injected automatically.


class ApiClient @Inject constructor()

class UserRepository @Inject constructor(
    private val api: ApiClient
)

Point:
If the class can be instantiated directly, a Module is not required.

 

🧑🏻‍💻 When a Module Is Required (1): Interfaces

When injecting an interface, Hilt cannot determine which implementation to use.

You need to specify it explicitly using @Binds inside a Module.


interface Logger { 
    fun log(msg: String) 
}

class ConsoleLogger @Inject constructor() : Logger

@Module
@InstallIn(SingletonComponent::class)
interface LoggerModule {
    @Binds
    fun bindLogger(impl: ConsoleLogger): Logger
}

Point:
Interfaces always require a Module.

 

🧑🏻‍💻 When a Module Is Required (2): External Libraries

External libraries typically do not have @Inject constructor.

You must provide the creation logic inside a Module.


@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    fun provideClient(): OkHttpClient = OkHttpClient.Builder().build()
}

Point:
You make external classes injectable by defining how to create them in a Module.

 

🧑🏻‍💻 Summary

  • Your own concrete classes → auto-injectable
  • Interfaces & external libraries → Module required
  • Multiple implementations or singleton handling → use @Qualifier, @Named, @Singleton

👉 Dagger/HiltでModuleが必要か一目でわかるようにパターン化する


Kotlin StateFlow: value vs. update – Which One Should You Use?

When updating a MutableStateFlow, you have two options. Here is how to decide instantly.

 

🧑🏻‍💻 The Golden Rule

  • Use update { } if the new value depends on the current value (e.g., incrementing a counter, toggling a boolean).
  • Use value = if you are completely overwriting the state (e.g., setting a loading state, resetting data).

 

🧑🏻‍💻 Why does it matter?

Direct assignment (value = ...) is not thread-safe for "read-modify-write" operations.

If two coroutines try to update the state simultaneously using .value = .value + 1, you risk a Race Condition where one update is lost.

The update function is atomic. It uses a Compare-And-Set mechanism to ensure that updates happen sequentially and safely, even across multiple threads.

 

🧑🏻‍💻 Code Comparison

❌ Risky (Race Condition prone)


// If called concurrently, updates might be lost
_uiState.value = _uiState.value.copy(count = _uiState.value.count + 1)

✅ Safe (Thread-safe)


// Guarantees consistency
_uiState.update { it.copy(count = it.count + 1) }

✅ Safe (Overwrite)


// No race condition risk because we ignore the previous state
_uiState.value = UiState.Loading

 

🧑🏻‍💻 Summary

When in doubt, use update. It is safer by default and prevents subtle concurrency bugs.


Why Kotlin Channels Are the Natural Solution for Preventing "Double Execution" of Events

In Android app development, handling "one-shot events"—such as screen navigation or displaying toasts—has traditionally been a tricky area. The issue of processes running a second time after a screen rotation (Activity recreation) is a common headache for developers.

However, once you understand the mechanics of Channel, you will see that it is the most natural and effective solution to this problem.

 

🤔 Channels Have No "Inventory": Retrieve It, and It’s Gone

The decisive difference between Channel and its counterparts like StateFlow (or LiveData) lies in how they treat data.

  • StateFlow (The Bulletin Board): Data is "held" as a state. Even after someone looks at the data, it remains there. Any new observer will see the same, most recent data immediately (this is called being "sticky").
  • Channel (The Pipe/Mailbox): Data is meant to "pass through." The moment the receiver (Consumer) calls receive or collect, that specific piece of data is removed (consumed) from the Channel.

This concept of "consumption" is the essence of a Channel. Just like taking a letter out of a physical mailbox leaves the box empty, an event that has been processed once does not linger in memory.

 

🤔 How It Prevents Unintended Re-execution During Screen Rotation

This "disappears upon retrieval" behavior is incredibly effective during the recreation of Activities or Fragments. Let's look at the specific flow:

  • Event Fired: The ViewModel executes channel.send("Error").
  • Consumption: The UI (Activity A) collects this and shows a Toast. At this exact moment, the Channel becomes empty.
  • Screen Rotation: Activity A is destroyed, and Activity B is newly created.
  • Re-subscription: The new Activity B begins to collect from the channel again.

If this were StateFlow, the last piece of data ("Error") would still be there, causing Activity B to receive it immediately and trigger a second Toast.

However, the Channel is empty. Activity B simply enters a state of waiting for new events. It is impossible for the past event to trigger a malfunction.

 

🤔 Implementation Tip: receiveAsFlow and Buffering

In actual production code, it is standard practice to not expose the raw Channel, but rather expose it as a Flow to the receiver.


// ViewModel
// Use a buffer to prevent dropping events if the collector isn't ready immediately
private val _events = Channel<Event>(Channel.BUFFERED)
val events = _events.receiveAsFlow() // Exposed as a Flow

// UI (Activity/Fragment)
lifecycleScope.launch {
    viewModel.events.collect { event ->
        // Processing the event here consumes it from the Channel
        handleEvent(event)
    }
}

Using Channel.BUFFERED here is highly recommended. This ensures that if an event fires while the UI is not ready (e.g., the app is in the background), the event is held temporarily in memory. As soon as the UI resumes and calls collect, the event is delivered and consumed safely.

 

🤔 Summary: Channel is the Best Practice for "Disposable" Events

The "Receive = Consume" behavior of a Channel is not a side effect; it is the core feature designed for managing tasks that should only happen once.

The rule of thumb is simple:

  • If you need to hold a state (like UI text or loading status), use StateFlow.
  • If you need to consume an event (like navigation or errors), use Channel.

By strictly following this distinction, you can achieve safe, clean event handling without relying on complex flags or workarounds.