Dependency Injection Without a Framework
Dependency Injection (DI) is a design principle where a class receives its
dependencies from the outside rather than creating them internally. You do not need
Spring, Guice, or any framework to apply DI - it works with plain Java.
The problem: hard-coded dependencies
// BAD: UserService creates its own dependencies
class UserService {
private final UserRepository repo = new PostgresUserRepository();
private final EmailService email = new SmtpEmailService();
User createUser(String name, String emailAddr) {
User user = new User(name, emailAddr);
repo.save(user);
email.sendWelcome(user);
return user;
}
}
This is hard to test (you need a real database and SMTP server), hard to reconfigure
(switching from Postgres to MySQL requires editing the class), and violates the
Single Responsibility Principle (the class knows how to construct its dependencies).
Constructor injection
The simplest and most effective form of DI:
// GOOD: dependencies are injected through the constructor
class UserService {
private final UserRepository repo;
private final EmailService email;
UserService(UserRepository repo, EmailService email) {
this.repo = repo;
this.email = email;
}
User createUser(String name, String emailAddr) {
User user = new User(name, emailAddr);
repo.save(user);
email.sendWelcome(user);
return user;
}
}
Why this is better
Wiring it together
// Production wiring (the "composition root")
public class Application {
public static void main(String[] args) {
DataSource ds = createDataSource();
UserRepository repo = new PostgresUserRepository(ds);
EmailService email = new SmtpEmailService("smtp.example.com", 587);
UserService userService = new UserService(repo, email);
userService.createUser("Alice", "[email protected]");
}
}
// Test wiring
class UserServiceTest {
@Test
void shouldCreateUser() {
UserRepository repo = new InMemoryUserRepository();
EmailService email = new FakeEmailService();
UserService service = new UserService(repo, email);
User user = service.createUser("Alice", "[email protected]");
assertThat(repo.findByName("Alice")).isPresent();
assertThat(email.sentEmails()).hasSize(1);
}
}
Program to interfaces
DI works best when classes depend on interfaces (or abstract classes), not
concrete implementations:
// Interface
interface UserRepository {
void save(User user);
Optional<User> findById(String id);
Optional<User> findByName(String name);
List<User> findAll();
}
// Production implementation
class PostgresUserRepository implements UserRepository {
private final DataSource dataSource;
PostgresUserRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void save(User user) {
// JDBC code
}
// ...
}
// Test implementation
class InMemoryUserRepository implements UserRepository {
private final Map<String, User> store = new HashMap<>();
@Override
public void save(User user) {
store.put(user.id(), user);
}
@Override
public Optional<User> findById(String id) {
return Optional.ofNullable(store.get(id));
}
// ...
}
Factory methods for complex wiring
When construction logic gets complex, extract it into a factory:
class ServiceFactory {
private final Config config;
ServiceFactory(Config config) {
this.config = config;
}
UserRepository createUserRepository() {
return switch (config.dbType()) {
case "postgres" -> new PostgresUserRepository(config.dataSource());
case "memory" -> new InMemoryUserRepository();
default -> throw new IllegalArgumentException("Unknown DB: " + config.dbType());
};
}
EmailService createEmailService() {
if (config.isTestMode()) {
return new FakeEmailService();
}
return new SmtpEmailService(config.smtpHost(), config.smtpPort());
}
UserService createUserService() {
return new UserService(createUserRepository(), createEmailService());
}
}
The Service Locator anti-pattern
A Service Locator is a global registry where classes look up their dependencies
at runtime:
// BAD: Service Locator pattern
class UserService {
User createUser(String name, String email) {
// Hidden dependency - not visible in the constructor
UserRepository repo = ServiceLocator.get(UserRepository.class);
EmailService emailSvc = ServiceLocator.get(EmailService.class);
// ...
}
}
Why Service Locator is problematic
Rule: Always prefer constructor injection. The constructor is the single source
of truth for a class's dependencies.
Method injection (rare)
Sometimes a dependency is only needed for one method, or varies per call:
class ReportGenerator {
// The formatter varies per report - inject it into the method
String generate(Report report, ReportFormatter formatter) {
return formatter.format(report);
}
}
// Usage
generator.generate(report, new PdfFormatter());
generator.generate(report, new CsvFormatter());
Setter injection (avoid)
// AVOID: allows partially constructed objects
class UserService {
private UserRepository repo;
void setUserRepository(UserRepository repo) {
this.repo = repo;
}
User createUser(String name) {
repo.save(new User(name)); // NullPointerException if setter not called!
// ...
}
}
Setter injection leaves the object in an invalid state between construction and
setter calls. Use constructor injection to guarantee all dependencies are present.
DI in practice: layered architecture
// Data layer
interface OrderRepository { /* ... */ }
class JdbcOrderRepository implements OrderRepository {
JdbcOrderRepository(DataSource ds) { /* ... */ }
}
// Service layer
class OrderService {
private final OrderRepository orderRepo;
private final PaymentService paymentService;
private final NotificationService notificationService;
OrderService(OrderRepository orderRepo, PaymentService paymentService,
NotificationService notificationService) {
this.orderRepo = orderRepo;
this.paymentService = paymentService;
this.notificationService = notificationService;
}
void placeOrder(Order order) {
orderRepo.save(order);
paymentService.charge(order.customer(), order.total());
notificationService.sendConfirmation(order);
}
}
// Composition root
class Application {
public static void main(String[] args) {
Config config = Config.load();
DataSource ds = createDataSource(config);
// Wire everything together
OrderRepository orderRepo = new JdbcOrderRepository(ds);
PaymentService payment = new StripePaymentService(config.stripeKey());
NotificationService notification = new EmailNotificationService(config.smtpConfig());
OrderService orderService = new OrderService(orderRepo, payment, notification);
// Start the application (HTTP server, CLI, etc.)
new HttpServer(orderService).start(8080);
}
}
When to use a framework
Manual DI (constructor injection + a composition root) works well for small-to-medium
applications. Consider a DI framework when:
Spring example (for comparison)
@Service
class UserService {
private final UserRepository repo;
private final EmailService email;
// Spring auto-injects via constructor
UserService(UserRepository repo, EmailService email) {
this.repo = repo;
this.email = email;
}
}
Spring does exactly what manual DI does - it just automates the wiring. The constructor
still receives its dependencies, and the class is still testable without Spring.
Common pitfalls