跪拜 Guibai
← Back to the summary

DI Isn't About Saving `new` — It's About Who Owns the Dependency Graph

When I first started writing Android projects, I didn't really understand why DI was necessary.

It's just an object:

val repository = UserRepositoryImpl()

Why not just create it directly?

If it's just a small project, there's really no problem.

I even felt that introducing a dependency injection framework like Hilt or Koin at that stage was a bit heavy.

What really changed my mind was when the project started to grow.

You'll find that as the project becomes more complex, trouble arrives:

Objects are being created everywhere.


1. When the project is small, creating objects directly is fine

For example:

class UserRepository {

    fun getUser() {
        // ...
    }
}

When using it:

class UserViewModel {

    private val repository = UserRepository()

    fun loadUser() {
        repository.getUser()
    }
}

Totally fine.

Because at this point:

So:

UserRepository()

is not a problem in itself.

The real problem is that as the project grows, new starts appearing in more and more places.


2. The real trouble is: business code starts taking responsibility for "assembling objects"

Suppose the login feature is simple at first:

class LoginServiceImpl(
    private val repository: UserRepository
)

Creation:

val service = LoginServiceImpl(repository)

No problem.

Later, requirements gradually increase.

Login needs configuration:

class LoginConfig

Needs a security component:

class SecurityManager

Eventually it might become:

class LoginServiceImpl(
    private val repository: UserRepository,
    private val config: LoginConfig,
    private val logger: Logger,
    private val securityManager: SecurityManager
)

So the code creating it also becomes complex:

val service = LoginServiceImpl(
    repository,
    config,
    logger,
    securityManager
)

At this point, the real problem emerges.

Whoever creates LoginServiceImpl must know what it depends on.

In other words:

LoginViewModel
      ↓
knows LoginServiceImpl
      ↓
knows it needs Repository
      ↓
knows it needs Config
      ↓
knows it needs SecurityManager

Business code starts learning about more and more infrastructure.

This is where the trouble begins.


3. Object creation itself isn't complex; what's complex is the dependency relationships

Many people first encountering DI understand it as:

Hilt helps me create objects.

This understanding is too shallow.

Because creating an object is really simple:

UserRepositoryImpl()

What's truly complex is:

UserViewModel
      ↓
LoginUseCase
      ↓
LoginRepository
      ↓
UserApi
      ↓
Retrofit
      ↓
OkHttp

This is no longer "creating an object."

This is an entire object dependency graph.

And as the project continues to grow, this graph might become:

                    Retrofit
                       ↓
                    UserApi
                       ↓
                UserRepository
                  ↙        ↘
             Database      Cache
                  ↓          ↓
                 Room      DataStore


UserRepository
       ↓
   LoginUseCase
       ↓
  LoginViewModel
       ↓
       UI

What's truly hard to manage is this graph.


4. If every business class can create objects, dependency relationships will eventually spiral out of control

For example:

class LoginViewModel {

    private val api =
        Retrofit.Builder()
            .baseUrl("...")
            .build()
            .create(UserApi::class.java)
}

At first you might think:

As long as it runs.

But this way, LoginViewModel already knows about:

ViewModel should be concerned with:

User clicks login
        ↓
Execute login
        ↓
Display result

Now it starts worrying about:

How Retrofit is initialized
How the API is created
What the BaseUrl is

Business code and infrastructure are bound together.

Later, if you want to replace Retrofit, you'll find:

It's not that you can't change it, but changing it feels very awkward.


5. What DI really does is take away the "decision-making power"

So the real change DI brings is:

LoginViewModel no longer decides who LoginService is.

It only declares:

class LoginViewModel @Inject constructor(
    private val loginService: LoginService
)

ViewModel only cares about one thing:

I need a LoginService.

As for:

Is it LoginServiceImpl?

Or CacheLoginServiceImpl?

Or FakeLoginService?

What parameters does it need?

Where do these parameters come from?

What is the lifecycle?

These things are no longer decided by ViewModel.


6. This is IoC: Control has been inverted

Without DI:

Business Code
   ↓
I want LoginService
   ↓
I create it myself
   ↓
LoginServiceImpl
   ↓
I resolve its dependencies myself

Business code holds the control.

With DI:

Business Code
   ↓
Declares:
I need LoginService
   ↓
DI
   ↓
Decides which implementation to use
   ↓
Resolves its dependencies
   ↓
Creates the object
   ↓
Injects into business code

So what IoC (Inversion of Control) truly "inverts" is not:

The method of creating objects.

But rather:

Who owns the control over dependency relationships.

Previously, business code decided.

Now, the architectural layer decides.


7. The larger the project, the more important this becomes

Because the larger the project, the more complex the dependency relationships.

A small project might only have:

ViewModel
   ↓
Repository

Direct creation is totally fine.

A medium project might already have become:

ViewModel
   ↓
UseCase
   ↓
Repository
   ↓
Api
Database
Cache
Config
Logger

A large project might also need to consider:

Different implementations
Different environments
Different modules
Different lifecycles
Different configurations

At this point, if every business class can decide:

Which object I want to create.

Eventually, the entire project will encounter a very troublesome situation:

Dependency relationships are scattered across every corner of the business code.

You simply don't know where an object is actually being created.


8. What's truly worth paying attention to is "implementation class changes"

For example, now:

interface LoginService {

    fun login()
}

Production environment:

class LoginServiceImpl : LoginService

Test environment:

class FakeLoginService : LoginService

Later, due to business requirements, caching is added:

class CacheLoginService : LoginService

If business code directly depends on the implementation:

val service = LoginServiceImpl()

Then when the implementation changes, the business code must also change.

But if business code only depends on the interface:

class LoginViewModel(
    private val service: LoginService
)

Then:

LoginViewModel
       ↓
LoginService
       ↑
       |
 ┌─────┼────────────┐
 ↓     ↓            ↓
Impl  Fake       CacheImpl

No matter how the implementation changes, the business code doesn't need to know.

This is the true value when DI and abstraction are combined.


9. Constructor parameter changes are also a very real problem

For example, initially:

class LoginServiceImpl(
    private val repository: UserRepository
)

Creation:

LoginServiceImpl(repository)

Later:

class LoginServiceImpl(
    private val repository: UserRepository,
    private val config: LoginConfig
)

Even later:

class LoginServiceImpl(
    private val repository: UserRepository,
    private val config: LoginConfig,
    private val logger: Logger
)

If this object is directly created in many places:

LoginServiceImpl(...)

After the constructor changes, all creation points might be affected.

And this is where the significance of DI lies.

Let:

LoginServiceImpl

have its creation concentrated in the dependency configuration location.

For example, Hilt:

@Module
@InstallIn(SingletonComponent::class)
object LoginModule {

    @Provides
    fun provideLoginService(
        repository: UserRepository,
        config: LoginConfig,
        logger: Logger
    ): LoginService {
        return LoginServiceImpl(
            repository,
            config,
            logger
        )
    }
}

Business code doesn't need to follow constructor parameter changes.

It still is just:

class LoginViewModel @Inject constructor(
    private val loginService: LoginService
)

10. So what DI manages is actually an object graph

This is also what I now think is the most important step in understanding DI.

Don't just understand DI as:

@Inject
    ↓
Automatically create object

You should understand it as:

                 UserApi
                    ↑
                    |
              UserRepository
                    ↑
                    |
                LoginUseCase
                    ↑
                    |
              LoginViewModel

What DI does is connect these dependency relationships.

That is:

Who depends on whom
      ↓
Who implements whom
      ↓
Who is responsible for creation
      ↓
Who is responsible for managing lifecycles
      ↓
What implementation to use in different environments

Once these things are centrally managed, business code can become much cleaner.


11. When should you start considering DI?

I think you can look at a very simple signal:

When a business class starts caring more and more about "how its dependencies are created," it's time to consider taking away the creation power.

For example:

class UserViewModel {

    private val retrofit = ...
    private val api = ...
    private val database = ...
    private val repository = ...
}

If a ViewModel becomes like this:

Responsible for business
Also responsible for creating Retrofit
Also responsible for creating Repository
Also responsible for managing configuration
Also responsible for deciding lifecycles

Then the problem is no longer just too much code.

It's that:

Responsibilities have started to mix together.

At this point, DI becomes meaningful.


Finally, back to the original question

Does DI help us create objects?

Yes.

But that's only the most superficial layer.

What's truly worth understanding is:

Small Project

UserRepository()
        ↓
Direct creation
        ↓
No problem

As the project grows:

UserRepository
       ↓
Api
Database
Cache
Config
Logger
       ↓
Dependencies increase
       ↓
Object creation becomes more complex
       ↓
Business code starts caring about infrastructure

At this point, the creation power needs to be taken away:

Business Code
    ↓
Only declares dependencies
    ↓
Interface / Abstraction
    ↓
DI
    ↓
Decides implementation
    ↓
Resolves dependencies
    ↓
Manages lifecycle

So now I prefer to understand DI this way:

DI is not about saving you from writing a few lines of new.

What it truly solves is: when the system becomes more and more complex, who is responsible for deciding the dependency relationships between objects.

In a small project, creating objects yourself is fine.

But when dozens or hundreds of objects and complex dependency relationships start appearing in a project, if every business class holds the power to "create dependencies," the system can easily spiral out of control.

So the larger the project, the more this power should be taken out of the business code.

Business code is responsible for "what I want."

Architecture is responsible for "what to give you." With DI, we can focus on business development.

Github Sample