The Case Against Dependency Injection

Programming Jun 11, 2025

I first met with Dependency Injection when I on-boarded myself on a large backend project that used Scala and Play framework. Over time, I have convinced myself that dependency injection is a good way of managing dependencies, but recently, I have come to the conclusion most of the time, it hurts more than it helps.

1 - Interfaces, Objects and Classes

One argument Dependency Injection frameworks give is how your implementation is decoupled from the interface. I would like to ask you, how many times your interfaces had multiple implementations? Moreover, which one of those you wanted to abstract out the implementation being passed? I have seen many occasions where developers created interfaces pre-emptively that didn't provide any value because the standard way of doing things is "Dependency Injection" and they create interfaces to decouple the implementation. Hand on heart, did that interface achieve anything real, or is it just a pattern we have been following without thinking much, because we have been advertised this framework allows us to "separate concerns" by allowing us to inject interfaces?

interface IBookRepository {
  fun getBooks(): List<Book>
}

class BookRepository : IBookRepository {
  override fun getBooks() = Books.selectAll().toList()
}

Do you really need the interface IBookRepository when you only have one datasource that holds up books? Even if you had multiple sources, why would you inject different types of implementation in your code? One possibility is choosing different implementation for local, testing and production environments, however I think it just makes testing less effective, as you have different behavior in different environments now.

Let's remember, objects are also still a cool alternative to @Singleton injection.

object BookRepository {
  fun getBooks() = Books.selectAll().toList()
}

object BookController {
  fun getBooks() = BookRepository.getBooks() 
}

There isn't a clear reason to me on why this is less acceptable than the dependency injected implementation. Moreover, dependency injection spreads like a plauge, because you can no longer access the BookRepository instance easily from a class / object that is not created via the dependency injection framework. So anything that depends on something dependency injected, needs to be dependency injected itself.

2 - Testing

Testing is not easier if you have constructor with bunch of unrelated dependencies. Some argue seeing dependencies explicitly enforces you to not miss them while writing tests and have fully intended behavior. I don't see it, on contrary I would argue they shift the focus away from the thing that is actually being tested. You write bunch of boilerplate things that you did not really need to test a method that only depends on a single dependency, yet so still initialized the class with 10+ dependencies.

class BookController(
  authenticator: Authenticatior,
  bookRepository: BookRepository,
  userRepository: UserRepository,
  libraryRepository: LibraryRepository,
) {
  fun getPublicBooks() = bookRepository.getPublicBooks() 

  ...
}

// While testing

class BookControllerTest {
  
  @Test
  fun `should get public books`() {

    val mockBookRepository = mockk<BookRepository>()

    // Initialization gets longer and longer over time
    val sut = BookController(
        authenticator = mockk(),
        bookRepository = mockBookRepository,
        userRepository = mockk(),
        libraryRepository = mockk(),
    )

    every { mockBookRepository.getPublickBooks() } returns listOfBooks

    sut.getPublicBooks() should be listOfBooks
  }
}

If we are talking about the Controllers or Services, their responsibilities grow over time quickly. Therefore their constructor bloats and causes developers to juggle bunch of test code to make it work. One way you can get away is using property injection rather than constructor. Therefore I prefer using property injection more than constructor injection, especially for those complicated classes with multiple responsibilities (yes I think it is perfectly normal to have them in real life). However the alternative, the mocking libraries can handle testing aspect pretty well, if your language supports it.

class BookControllerTest {
  
  @Test
  fun `should get public books`() {
    mockkObject(BookRepository)

    every { BookRepository.getPublickBooks() } returns listOfBooks

    sut.getPublicBooks() should be listOfBooks
  }
}

I am not sure why this is a lot worse than spinning the DI framework or initializing classes by constructor during test run. Is it a real advantage that specifying all dependencies manually ensures there is no unintended behavior?

Personal experience, we have deliberately created instances of those classes using the framework provided builders rather than calling the constructor by hand, because it created such a huge overhead while writing tests. Therefore it lead to our constructor to be not called while initializing in tests. We deliberately got rid of that feature because it was such a pain to manage those long dependency lists by hand.

3 - Named Injection

Named injection is even worse, why are you messing up with your statically typed language by trying to declare classes with strings? If you have multiple implementations, just use the desired implementation with a proper downcast to the interface, don't use named injection to pull a specific implementation.

val RestClient = named<IClient>("rest")
val GrpcClient = named<IClient>("grpc")

// Instead...

val RestClient: IClient = RestClientImpl
val GrpcClient: IClient = GrpcClientImpl

I can't find an example for requiring multiple instances of the same object (not a singleton), but you can easily create multiple Instances as so

object ClientPool {
  val client1 = Client.new()
  val client2 = Client.new()
}

Or maybe all you need is an ObjectPool to begin with. Alternatively, leverage your language's type features and just extend the base interface with no modifications to save yourself from some headaches.

interface Logger {
    fun log(text: String)
}

interface PrettyLogger : Logger
interface RegularLogger : Logger

object PrettyLoggerImpl : PrettyLogger { ... }
object RegularLoggerImpl : RegularLogger { ... }

4 - Cross Compatability

If you ever imported a library that uses a dependency injection framework and tried to adopt into your own dependency injection system, good luck with that. You are bringing bunch of dependencies that you don't really understand how it works under the hood, and moreover you now have to make it work properly with your dependency injection system, which is an abstraction that helps you to not deal with managing dependencies yourself, but to your surprise, now you have to know how both DI systems work under the hood and interact together.

So if you ever create a re-usable library, please don't use the modern DI frameworks, you should rely on your language features as much as possible. If you ever feel stuck, build something that works standalone, not a part of the DI system such as Spring or Dagger.

object LoggerProvider {
  val logger: Logger = when(LoggerConfig.type) {
    "pretty" -> PrettyLoggerImpl
    else -> RegularLoggerImpl
  }
}

Don't be afraid of creating your own abstractions to fit your own needs, I think it is perfectly normal and a common pattern in many different libraries.

5 - Final Remarks

I'm not against dependency injection, but I think we are making ourselves excuses to think it is the best way of managing dependencies and instances around. Instead I wanted to show you how it creates "self-fulfilling prophecies", when it makes sense and when it doesn't. I see when dependency injection might make sense, where implementations change quickly, they differ platform to platform, environment to environment etc. However it is important to understand when we really need it, versus when it just looks cool.


If you are new to dependency injection, I don't think this article makes a lot of sense. Therefore I have decided to move this "mini-introduction" to the end of the article, as a foot-note to the readers.

0 - Types of Dependency Injection

The default approach to dependency injection is Constructor Injection. This type of injection ensures your dependencies are not lazily evaluated and thus your class can be created if and only if your dependencies have been already initialized successfully, unlike property injection. A constructor dependency injected class might look like so,

public class BookController(
  authenticator: Authenticator,
  bookRepository: BookRepository,
) {
  fun getBooks() = authenticator.protect { 
    bookRepository.getBooks() 
  }
}

Whereas, a property injection might look like so,

public class BookController {
  var authenticator: Authenticator
  var bookRepository: BookRepository

  ...
}

Since you should create an instance of BookController without providing the dependencies and later set them, it doesn't have guardrails that prevent you from calling method such as getBooks before bookRepository is set. Therefore it is seen as a less desired way of dependency injection, however it provides some flexibility which is useful during testing and it helps application to initialize in a partial-state, which might be desired in some cases over total-blackout.

In some frameworks such as Koin, you can use language specific features such as lateinit or lazy initialization in Kotlin and methods provided by Koin framework to initialize properties automatically.

public class BookController : KoinComponent {
  private val authenticator : Authenticator by inject()
  private val bookRepository : BookRepository by inject()
}

However this couples your classes directly with Koin, so it should be omitted for shared code if possible, otherwise you enforce users to use Koin to ensure classes are initialized properly.

Software Engineer – Distributed systems, databases, service frameworks, open-source software, programming languages

Tags: