Why I Stopped Using Spring’s @Autowired and Adopted Explicit Dependency Management
The article explains how automatic @Autowired injection can tightly couple domain models to the Spring container, hide dependencies, hinder testing, create circular‑dependency risks, and violate clean architecture, then demonstrates an explicit‑dependency approach with a custom SpringContext utility, compares both methods, and offers practical guidelines for layered design and testing.
Spring's @Autowired annotation enables automatic bean injection but can introduce hidden dependencies, tight coupling between domain models and the framework, testing difficulties, circular‑dependency risks, and violations of clean‑architecture principles.
Four sins of automatic injection
Dependency black‑boxing : Dependencies become implicit and invisible, contradicting the “explicit over implicit” design rule.
Unit‑test dilemma : Testing domain objects requires starting the full Spring container, turning unit tests into slower integration tests.
Circular‑dependency breeding ground : Mutual @Autowired injections trigger Spring's three‑level cache, masking circular dependencies and leading to tangled architecture.
Breaking rich domain models : In DDD, domain objects should remain pure POJOs; auto‑injection forces them to be aware of the Spring container.
Explicit dependency management
A SpringContext utility implements ApplicationContextAware and provides static methods to retrieve beans explicitly.
@Primary
public class SpringContext implements ApplicationContextAware, PriorityOrdered, ApplicationRunner {
private static ApplicationContext applicationContext;
private static final CountDownLatch INITIALIZATION_LATCH = new CountDownLatch(1);
@Override
public void run(ApplicationArguments args) throws Exception {
INITIALIZATION_LATCH.countDown();
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
SpringContext.applicationContext = applicationContext;
}
public static <T> T getBean(Class<T> clazz) {
return applicationContext.getBean(clazz);
}
public static <T> T getBeanSync(Class<T> clazz) {
try {
if (!INITIALIZATION_LATCH.await(1, TimeUnit.MINUTES)) {
throw new IllegalStateException("Application initialization timeout");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Bean retrieval interrupted", e);
}
return applicationContext.getBean(clazz);
}
@Override
public int getOrder() {
return PriorityOrdered.HIGHEST_PRECEDENCE;
}
}Domain code can now obtain required services explicitly, keeping the model a pure POJO:
public class Order {
private Long orderId;
private BigDecimal amount;
private OrderStatus status;
public void pay() {
PaymentService paymentService = SpringContext.getBean(PaymentService.class);
PaymentResult result = paymentService.execute(this);
if (result.isSuccess()) {
this.status = OrderStatus.PAID;
DomainEventPublisher.publish(new OrderPaidEvent(this));
}
}
}Comparison of the two approaches
Domain model purity : Auto‑injection relies on the container; explicit retrieval yields a pure POJO.
Testability : Auto‑injection needs a Spring environment; explicit retrieval works with ordinary mocks.
Dependency visibility : Implicit with auto‑injection, explicit with the utility.
Circular‑dependency risk : High with auto‑injection, none with explicit retrieval.
Code readability : Requires IDE assistance for auto‑injection; explicit calls are immediately clear.
Best‑practice guide
Layered management strategy
Infrastructure layer: Allow @Autowired for technical components such as JPA repositories.
Domain layer: Prohibit container dependencies; use SpringContext to fetch required services.
Application layer: Prefer constructor injection with limited scope.
Async environment adaptation
public class AsyncBeanAccessor {
public static <T> Mono<T> getBeanReactive(Class<T> beanClass) {
return Mono.fromCallable(() -> SpringContext.getBean(beanClass))
.subscribeOn(Schedulers.boundedElastic());
}
}Testing support
@Test
public void testOrderPayment() {
SpringContextMock.registerMock(PaymentService.class, mockService);
Order order = new Order(/*...*/);
order.completePayment();
assertThat(order.getStatus()).isEqualTo(PAID);
}Architectural considerations
Control of technical boundaries : Frameworks should stay in the infrastructure layer and not leak into core business logic.
Complexity transfer : Explicit dependencies move complexity from runtime to compile‑time, aligning with the “Fail Fast” principle.
Evolutionary design : Keeping domain models framework‑agnostic eases future migrations.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Architect's Journey
E‑commerce, SaaS, AI architect; DDD enthusiast; SKILL enthusiast
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
