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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

ActiveRecord Query Patterns That Actually Scale

ActiveRecord makes simple queries trivial and complex queries dangerous. These are the patterns that remain correct under load — and the common ones that quietly fall apart at scale.

Read more

From CRUD to Domain Logic: Why Backend Systems Need Better Architecture

CRUD operations are easy to understand, but real backend systems do much more. Handling business rules, events, and integrations requires thoughtful architecture.

Read more

Why Remote Contractors Deliver Faster Than Office Teams

Remote contractors focus on results, not office presence. With fewer meetings and clearer scope, work moves faster and more efficiently.

Read more

The Danger of Sending Code Straight to Production Without Oversight

Pushing code directly to production might seem fast, but it’s a ticking time bomb. Without proper oversight, even small changes can cause massive headaches.

Read more