Manual Dependency Injection in Java — When It's Simpler Than Spring
by Eric Hanson, Backend Developer at Clean Systems Consulting
What dependency injection actually is
Dependency injection is a design pattern, not a framework feature. The core idea: a class receives its dependencies through its constructor (or setters, or method parameters) rather than creating them internally. The class describes what it needs; something else provides it.
// Without DI — class creates its own dependency
public class OrderService {
private final PaymentGateway gateway = new StripePaymentGateway(); // hardcoded
public void processOrder(Order order) {
gateway.charge(order.getTotal(), order.getPaymentMethod());
}
}
// With DI — dependency is provided externally
public class OrderService {
private final PaymentGateway gateway;
public OrderService(PaymentGateway gateway) {
this.gateway = Objects.requireNonNull(gateway);
}
public void processOrder(Order order) {
gateway.charge(order.getTotal(), order.getPaymentMethod());
}
}
The second version is testable — pass a mock PaymentGateway in tests. It's also flexible — swap StripePaymentGateway for BraintreePaymentGateway without modifying OrderService. Neither benefit requires a framework.
The composition root — where wiring happens
Manual DI requires a place where all dependencies are assembled. This is the composition root — typically the main method or an application bootstrap class:
public class Application {
public static void main(String[] args) {
// Configuration
AppConfig config = AppConfig.load();
// Infrastructure
DataSource dataSource = new HikariDataSource(config.getDataSourceConfig());
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
// Repositories
OrderRepository orderRepository = new JdbcOrderRepository(dataSource);
UserRepository userRepository = new JdbcUserRepository(dataSource);
// External services
PaymentGateway paymentGateway = new StripePaymentGateway(
config.getStripeApiKey(), httpClient
);
// Services — wired with their dependencies
OrderService orderService = new OrderService(
orderRepository, paymentGateway
);
UserService userService = new UserService(
userRepository, orderService
);
// HTTP layer
OrderController orderController = new OrderController(orderService);
UserController userController = new UserController(userService);
// Start the server
HttpServer server = new JettyServer(config.getPort());
server.register("/orders", orderController);
server.register("/users", userController);
server.start();
}
}
Every class is instantiated exactly once. Dependencies are passed explicitly. The entire dependency graph is visible in one place. No annotation scanning, no classpath magic, no startup reflection.
This is the composition root pattern. The composition root is the only place in the application that knows the full wiring. All other classes receive their dependencies and don't know how they were constructed.
What you gain with manual DI
Compile-time verification. The dependency graph is verified by the Java compiler. A missing dependency is a compile error, not a NoSuchBeanDefinitionException at startup. Refactoring a constructor signature — adding a required parameter, changing a type — produces compile errors at every call site that needs updating.
Fast startup. No classpath scanning, no proxy generation, no annotation processing at startup. A JVM application that wires itself manually starts in milliseconds. Spring Boot applications with a full context can take 10–30 seconds. For command-line tools, batch jobs, and test suites, this matters.
Transparent behavior. What a class depends on is visible in its constructor. What the application wires together is visible in the composition root. There's no framework machinery between the code you write and the behavior you see. Stack traces go directly to your code.
No framework API surface. Classes don't need @Autowired, @Component, @Service, @Bean. They're plain Java classes. They work identically in a Spring context, a manual composition root, a test, or a main method.
What you give up
Automatic lifecycle management. Spring manages bean scope (singleton, prototype, request scope), initialization callbacks (@PostConstruct), and destruction callbacks (@PreDestroy). Manual DI requires you to manage these explicitly — calling init() after construction, calling close() on shutdown.
Convention-based wiring for large graphs. For an application with 200 service classes, writing the composition root by hand is tedious. Spring's component scanning and auto-configuration handle this automatically. The composition root scales linearly with the number of components — manageable at 20, arduous at 200.
Declarative cross-cutting concerns. @Transactional, @Cacheable, @Async — Spring AOP applies these concerns transparently via proxies. Manual DI requires explicit wrapping or delegation:
// Spring: transparent
@Service
public class OrderService {
@Transactional
public void processOrder(Order order) { ... }
}
// Manual: explicit
public class TransactionalOrderService implements OrderService {
private final OrderService delegate;
private final TransactionManager txManager;
public void processOrder(Order order) {
txManager.executeInTransaction(() -> delegate.processOrder(order));
}
}
The manual version is more code but also more explicit — reading the class tells you exactly what transaction behavior it has without knowing Spring's proxy rules.
The size threshold
Manual DI works well for:
- Libraries and SDKs that don't want to impose a framework dependency on their consumers
- Command-line tools where startup time matters and the dependency graph is small
- Microservices with a focused scope — 10–30 service classes with clear ownership
- Test doubles and integration tests where you want exact control over what's wired
- Modules within a larger application where the module boundary is a composition root
Spring becomes worth its overhead for:
- Applications with 50+ service classes where the composition root would be hundreds of lines
- Web applications that need Spring MVC, Spring Security, Spring Data — the DI container comes with the web framework
- Applications that need AOP for transactions, caching, and security that should be applied transparently
- Teams with Spring expertise — the learning curve is already paid
The signal that manual DI is getting unwieldy: the composition root exceeds 100 lines and changes every time a new service is added. At that point, a lightweight DI container — Guice, Dagger, or Spring with Java config — reduces the boilerplate without requiring the full Spring ecosystem.
Partial adoption — manual DI inside Spring
Manual DI and Spring are not mutually exclusive. Individual modules within a Spring application can use manual wiring for their internal dependencies, exposing only a single entry-point bean to Spring:
@Configuration
public class PaymentModuleConfig {
@Bean
public PaymentService paymentService(
@Value("${stripe.api-key}") String apiKey,
HttpClient httpClient) {
// Manual wiring inside the Spring config
StripeClient stripeClient = new StripeClient(apiKey, httpClient);
PaymentValidator validator = new PaymentValidator();
PaymentAuditLog auditLog = new PaymentAuditLog();
return new PaymentService(stripeClient, validator, auditLog);
}
}
Spring manages PaymentService as a bean. The internal dependencies of the payment module — StripeClient, PaymentValidator, PaymentAuditLog — are wired manually and are invisible to Spring. These internal classes are plain Java with no Spring annotations, testable in isolation, and not subject to Spring's lifecycle or proxy behavior.
This hybrid approach gives you Spring's benefits at module boundaries (lifecycle, AOP, auto-configuration) with manual DI's benefits inside modules (compile-time verification, no annotation coupling, fast internal tests).
Testing without a container
The primary advantage of constructor-injected plain Java classes: tests are fast and explicit.
class OrderServiceTest {
private OrderService service;
private OrderRepository repository;
private PaymentGateway gateway;
@BeforeEach
void setUp() {
repository = mock(OrderRepository.class);
gateway = mock(PaymentGateway.class);
service = new OrderService(repository, gateway); // no Spring context
}
@Test
void chargesPaymentOnProcessOrder() {
Order order = new Order("ord-123", items, Money.of(100, "USD"));
when(repository.findById("ord-123")).thenReturn(Optional.of(order));
service.processOrder("ord-123");
verify(gateway).charge(Money.of(100, "USD"), order.getPaymentMethod());
}
}
No @SpringBootTest, no application context loading, no database, no network. The test constructs exactly what it needs and asserts on it. This test starts in milliseconds and fails with a clear message.
Spring-context tests (@SpringBootTest) are valuable for integration testing. They're expensive for unit testing where the goal is testing one class's behavior in isolation. Constructor injection makes the choice explicit — you can always instantiate the class manually in tests regardless of how it's wired in production.