As always, It Depends™. The answer depends on the problem one is trying to solve. In this answer, I'll try to address some common motivating forces:
Favour smaller code bases
If you have 4,000 lines of Spring configuration code, I suppose that the code base has thousands of classes.
It's hardly an issue that you can address after the fact, but as a rule of thumb, I tend to prefer smaller applications, with smaller code bases. If you're into Domain-Driven Design, you could, for instance, make a code base per bounded context.
I'm basing this advice on my limited experience, since I've written web-based line-of-business code for most of my career. I could imagine that if you're developing a desktop application, or an embedded system, or other, that things are harder to pull apart.
While I do realise that this first advice is easily the least practical, I also believe that it's the most important, and that's why I include it. Complexity of code varies non-linearly (possibly exponentially) with the size of the code base.
Favour Pure DI
While I still realise that this question presents an existing situation, I recommend Pure DI. Don't use a DI Container, but if you do, at least use it to implement convention-based composition.
I don't have any practical experience with Spring, but I'm assuming that by configuration file, an XML file is implied.
Configuring dependencies using XML is the worst of both worlds. First, you lose compile-time type safety, but you don't gain anything. An XML configuration file can easily be as big as the code it tries to replace.
Compared to the problem it purports to address, dependency injection configuration files occupy the wrong place on the configuration complexity clock.
The case for coarse-grained dependency injection
I can make a case for coarse-grained dependency injection. I can also make a case for fine-grained dependency injection (see next section).
If you only inject a few 'central' dependencies, then most classes might look like this:
public class Foo
private readonly Bar bar;
this.bar = new Bar();
// Members go here...
This is still fits Design Patterns's favor object composition over class inheritance, because
Bar. From a maintainability perspective, this could still be considered maintainable, because if you need to change the composition, you simply edit the source code for
This is hardly less maintainable than dependency injection. In fact, I'd say that it's easier to directly edit the class that uses
Bar, instead of having to follow the indirection inherent with dependency injection.
In the first edition of my book on Dependency Injection, I make the distinction between volatile and stable dependencies.
Volatile dependencies are those dependencies that you should consider injecting. They include
- Dependencies that must be re-configurable after compilation
- Dependencies developed in parallel by another team
- Dependencies with non-deterministic behaviour, or behaviour with side-effects
Stable dependencies, on the other hand, are dependencies that behave in well defined manner. In a sense, you could argue that this distinction makes the case for coarse-grained dependency injection, although I must admit that I didn't entirely realise that when I wrote the book.
From a testing perspective, however, this makes unit testing harder. You can no longer unit test
Foo independent of
Bar. As J.B. Rainsberger explains, integration tests suffer from a combinatoric explosion of complexity. You'll literally have to write tens of thousands of test cases if you want to cover all paths through an integration of even 4-5 classes.
The counter-argument to that is that often, your task isn't to program a class. Your task is to develop a system that solves some specific problems. This is the motivation behind Behaviour-Driven Development (BDD).
Another view on this is presented by DHH, who claims that TDD leads to test-induced design damage. He also favours coarse-grained integration testing.
If you take this perspective on software development, then coarse-grained dependency injection makes sense.
The case for fine-grained dependency injection
Fine-grained dependency injection, on the other hand, could be described as inject all the things!
My main concern regarding coarse-grained dependency injection is the criticism expressed by J.B. Rainsberger. You can't cover all code paths with integration tests, because you need to write literally thousands, or tens of thousands, of test cases to cover all code paths.
The proponents of BDD will counter with the argument that you don't need to cover all code paths with tests. You only need to cover those that produce business value.
In my experience, however, all the 'exotic' code paths will also execute in a high-volume deployment, and if not tested, many of those will have defects and cause run-time exceptions (often null-reference exceptions).
This has caused me to favour fine-grained dependency injection, because it enables me to test the invariants of all objects in isolation.
Favour functional programming
While I lean towards fine-grained dependency injection, I've shifted my emphasis towards functional programming, among other reasons because it's intrinsically testable.
The more you move towards SOLID code, the more functional it becomes. Sooner or later, you may as well take the plunge. Functional architecture is Ports and Adapters architecture, and dependency injection is also an attempt and Ports and Adapters. The difference, however, is that a language like Haskell enforces that architecture via its type system.
Favour statically typed functional programming
At this point, I've essentially given up on object-oriented programming (OOP), although many of the problems of OOP are intrinsically coupled to mainstream languages like Java and C# more than the concept itself.
The problem with mainstream OOP languages is that it's close to impossible to avoid the combinatoric explosion problem, which, untested, leads to run-time exceptions. Statically typed languages like Haskell and F#, on the other hand, enable you to encode many decision points in the type system. This means that instead of having to write thousands of tests, the compiler will simply tell you whether you've dealt with all possible code paths (to an extent; it's no silver bullet).
Also, dependency injection isn't functional. True functional programming must reject the entire notion of dependencies. The result is simpler code.
If forced to work with C#, I prefer fine-grained dependency injection because it enables me to cover the entire code base with a manageable number of test cases.
In the end, my motivation is rapid feedback. Still, unit testing isn't the only way to get feedback.