Dependency Injection and Inversion of Control
TechLearningPro's enrollment handler grew a static 'Services.get(OrderRepository.class)' at the top of every method. The call compiled, the production container had a bean, and a u
Dependency Injection and Inversion of Control
Advanced System Design & Software Architecture · Lesson 19 · Low-Level Design and Design Patterns
Lesson Overview
TechLearningPro's enrollment handler grew a static Services.get(OrderRepository.class) at the top of every method. The call compiled, the production container had a bean, and a unit test could not construct the handler without starting Spring or stubbing a global locator. A second problem arrived when CertificateIssuer started depending on EnrollmentQuery and EnrollmentQuery started depending on CertificateIssuer "just to see if a certificate already exists." Spring Boot refused to start. The message was BeanCurrentlyInCreationException. Someone set spring.main.allow-circular-references=true, the context started, and the cycle remained.
Inversion of control means the framework calls your code: it constructs objects, injects collaborators, and invokes lifecycle callbacks. You do not pull collaborators out of a global table at the moment of use. Dependency injection is the specific technique of supplying those collaborators from outside, through a constructor, a setter, or a field. The composition root is the place that knows the concrete classes. Everywhere else programs to the ports from Hexagonal Architecture and Ports & Adapters.
This lesson uses Spring Framework's real container. Constructor injection is the default you should write. Since Spring Framework 4.3 a class with a single constructor does not need @Autowired on that constructor. @Configuration and @Bean methods are the composition root when component scan would hide a choice you need to see. Scopes are singleton unless you have a reason, and a reason for prototype, request, or session includes how the bean is obtained. ObjectProvider<T> is the API for an optional or lazy lookup that is still explicit at the injection point. Field injection, ApplicationContext.getBean, and circular dependencies are covered as the defects they are, with the Boot 2.6 default that circular references are prohibited unless the property above is set.
SOLID Principles in Practice already stated dependency inversion: high-level policy depends on an abstraction it owns. Injection is how a Spring process satisfies that abstraction at runtime. The principle does not require a container. A test that calls new ConfirmPaymentHandler(store, events, clock) is dependency injection. The container is for production wiring. Tests that need the container are testing the wiring, not the pricing rule.
Learning Objectives
By the end of this lesson you will be able to:
- Distinguish inversion of control, dependency injection, and dependency inversion, and say which one a given code change actually uses.
- Wire an enrollment handler with constructor injection and a
@Configurationcomposition root, without a service locator. - Choose singleton scope by default and justify
ObjectProvider, prototype, or request scope when a bean's lifecycle differs from the caller's. - Replace
ApplicationContext.getBeanwith constructor dependencies and name the few places a locator is still honest. - Detect a circular dependency, refuse
allow-circular-referencesas the fix, and break the cycle by moving a port. - Test the handler with fakes passed to the constructor, and use a Spring test only to prove the graph starts.
- Inject a
List<PricingPolicy>for strategies from lesson 17 without the policies depending on the selector.
Prerequisites
- Lesson 4's dependency inversion and interface segregation: the handler should not depend on a god "services" interface. Lessons 5 and 6's package rule. Lesson 17's policies and handlers. Lesson 18's
OrderRepositoryport. - Spring Boot 3 on Spring Framework 6. You will read
@Configuration,@Bean,@Component,@Qualifier,@Primary, andObjectProvider. You will not need a customBeanFactoryPostProcessor. - JUnit 5. Tests construct objects directly.
@SpringBootTestappears only as the wiring test, not as the default.
Detailed Core Concepts
Three terms that reviews mix up
Dependency inversion is a design rule about source code. The handler imports OrderRepository, an interface in the application package. The JDBC class imports the interface and implements it. The dependency arrow points toward the policy. You can do this with new in a main method and no framework.
Dependency injection is a mechanical rule about who passes the reference. The handler's constructor takes OrderRepository. Some other code supplies the implementation. The handler does not construct JdbcOrderRepository and does not look it up.
Inversion of control is the broader Hollywood principle: "don't call us, we'll call you." A servlet container calls doGet. Spring calls your @Bean methods, injects constructors, and calls @Transactional proxies around your methods. Dependency injection is one form of inversion of control. Event listeners from lesson 17 are another: the container calls the listener. You inverted control when you stopped writing the loop that wires the process and let the container do it. You can invert control badly, by letting the container call you and then immediately calling getBean for everything else. The locator puts the control back in the callee.
The practical test: can a JUnit method construct the object and pass fakes without a static registry and without SpringApplication.run? If yes, injection is real. If the method reaches for a global, the container is a service locator with extra steps.
Constructor injection
Constructor injection makes dependencies visible, final, and required. The object cannot be built in a half-initialized state. Spring's documentation recommends it for mandatory collaborators. A single constructor is used automatically from Spring Framework 4.3 onward; @Autowired is noise on that constructor and still harmless. Add @Autowired only when there are multiple constructors and Spring would otherwise not know which one to call. Prefer one constructor.
javapublic final class ConfirmPaymentHandler {private final EnrollmentStore store;private final EnrollmentEvents events;private final Clock clock;public ConfirmPaymentHandler(EnrollmentStore store, EnrollmentEvents events, Clock clock) {this.store = Objects.requireNonNull(store);this.events = Objects.requireNonNull(events);this.clock = Objects.requireNonNull(clock);}}
final fields are the point. A setter can be called twice, or never. Field injection (@Autowired on a private field) leaves the fields unset when you new the class in a test, so the test starts Spring or uses reflection. Both hide the dependency list from the constructor signature, which is the list a reviewer reads. Setter injection remains useful for optional framework callbacks and for a few circular cases Spring can satisfy by injecting a setter after construction. This lesson does not use setter injection to paper over a cycle. It removes the cycle.
Multiple beans of one type need @Qualifier or @Primary. @Primary means "the default when nobody asks." @Qualifier("earlyBird") means "this injection point is specific." A List<PricingPolicy> injection point receives every bean of that type without a qualifier, which is how lesson 17's selector is populated. Do not also mark one policy @Primary and then wonder why a single PricingPolicy parameter silently becomes the early-bird policy in a different class. If a class needs one policy, it should take PricingPolicies, the selector, not a raw PricingPolicy, unless a qualifier names the choice.
The composition root
The composition root is the unique area that knows concrete classes. In Spring Boot it is the application's @SpringBootApplication package plus @Configuration classes. Component scan is convenient and opaque: a new @Component on a policy joins the list, which is what you want for strategies, and a new @Component on a second OrderRepository fails startup with NoUniqueBeanDefinitionException, which is what you want if you did not intend a second repository. When the wiring itself is a decision — which gateway adapter, which clock — write a @Bean method so the choice is a line of code a review can diff.
java@Configurationclass EnrollmentConfig {@BeanClock clock() {return Clock.systemUTC();}@BeanConfirmPaymentHandler confirmPaymentHandler(EnrollmentStore store, EnrollmentEvents events, Clock clock) {return new ConfirmPaymentHandler(store, events, clock);}}
The handler is not a @Component in this style. The configuration class is the root. The store and the events adapter can be @Component classes in the adapter package, scanned because they are the obvious implementations, or they can be @Bean methods too when a profile chooses between a threshold gateway and an HTTP adapter. @Profile("local") on a test-double @Bean is how lesson 5 kept a fake gateway out of production. The root is allowed to mention concrete classes. The handler's file is not.
A composition root that lives inside the domain package, or a domain class that carries @Configuration, inverts the dependency rule: the core would import the adapter. Keep configuration in bootstrap. ArchUnit can forbid @Configuration under domain.
Scopes and ObjectProvider
The default scope is singleton: one instance per context, shared by threads, created eagerly unless you opt into lazy initialization. Collaborators of a singleton are singletons too, unless you introduce a scoped proxy or a lookup. That is the constraint lesson 17 already used when it forbade request state on policy beans.
prototype creates a new instance per injection request. A prototype injected into a singleton is created once, when the singleton is created, and then reused. The scope does not mean "new every method call" unless the singleton asks the container again. That second ask is a locator. ObjectProvider<T> is the explicit form Spring provides: the singleton depends on ObjectProvider<ExpensiveReport> and calls getObject() at the moment it needs a fresh prototype. The dependency is still on the constructor. It is not a hidden getBean("name") string. Use it when the prototype is genuinely per operation and construction is cheap enough to do there. If you call getObject() to dodge a cycle, you have a cycle.
Request and session scopes apply inside a web request. A request-scoped bean injected into a singleton needs a proxy (proxyMode on @Scope) or an ObjectProvider obtained during the request. The proxy is a real Spring mechanism and a real source of "the bean did not see my request" defects when code runs on a non-request thread, such as a @Async executor or the after-commit listener thread. After-commit listeners do not automatically see the request that started the transaction. Do not put a request-scoped CurrentUser on the listener and expect it to be populated. Pass the principal on the event record, as lesson 17's EnrollmentCompleted passed learnerId.
ObjectProvider methods you will actually use: getObject() when the bean is required, getIfAvailable() when it may be absent, and stream() or orderedStream() when you want the same set a List<T> would have given you, resolved at call time. Prefer List<T> for the strategy set if all strategies are singletons available at startup. Prefer ObjectProvider when resolution must be deferred or optional. jakarta.inject.Provider is the standard equivalent of a simple get() if you are writing a type that should not import Spring. An adapter can adapt ObjectProvider to jakarta.inject.Provider at the edge. The domain should not import either one if a normal constructor argument will do. Lookup is a capability with a cost. Most handlers need a constructor.
Service locator
ApplicationContext.getBean(Class) and static holders (Services.get) are service locators. The caller decides when to pull and names the type at the call site, which can be anywhere, including a domain entity. Tests cannot see the dependency. Startup might succeed because the call is on a rare branch, and production fails on the first refund with NoSuchBeanDefinitionException. Constructor injection fails startup every time a dependency is missing, which is the failure you want in a deploy, not during a refund.
The locator is honest in two places. The container's own bootstrap is a locator by nature: something constructs the context. A few generic framework hooks — a Spring MVC HandlerInterceptor registered outside your graph, or a library that gives you an ApplicationContextAware callback — sit at the edge. Your use cases do not. If a class has more than two getBean calls, it is an undeclared composition root in the wrong package.
@Autowired on a field is not a locator, but it has the locator's testing cost. Treat it as legacy when you find it. When you add a dependency, add a constructor parameter.
Circular dependencies
A cycle exists when creating A requires B and creating B requires A. Spring can sometimes break a cycle that uses setter or field injection by exposing an early reference. It cannot construct two objects whose constructors each require the other: there is no order that works. Spring Boot 2.6 and later set spring.main.allow-circular-references to false by default. The context fails with BeanCurrentlyInCreationException. Turning the flag on restores the old behavior for setter and field cycles and does nothing good for constructor cycles except keep the design illegal in a way the runtime tolerates. Leave the flag off.
@Lazy on a constructor parameter injects a proxy and defers the real lookup, which can silence the exception. The cycle still exists. The first call through the proxy may recurse until the stack overflows if both sides call each other in their constructors' aftermath, or it may work and hide a layering violation. Do not add @Lazy as the fix. Find the dependency that points the wrong way.
The enrollment cycle in the overview is a layering mistake. CertificateIssuer asking EnrollmentQuery whether the learner is active is a downward call toward enrollment state. EnrollmentQuery asking the issuer whether a certificate exists is a second path that makes enrollment depend on certificates. One of those questions is not the other's business. The issuer can take an EnrollmentStore port (it needs the enrollment) and the enrollment query can read a certificate table through its own repository if the UI must show a flag, or the completion event can carry "certificate issued" once the listener has written a row. The query does not call the issuer. Breaking a cycle by introducing a third bean both depend on is sometimes right — a small port — and sometimes a new cycle with more classes. Draw the arrows before adding @Lazy.
Testing with fakes
A fake implements the port and stores rows in a map. A stub returns canned answers. A mock, in the test-double taxonomy, verifies interactions. This lesson's handler tests use fakes: an in-memory store that enforces the idempotency key, a list-backed event port. They are passed to the constructor. No @ExtendWith(SpringExtension.class), no @MockBean.
@MockBean replaces a bean in a Spring test context. It is the right tool when you are testing a controller slice (@WebMvcTest) and the controller's dependency is a port you do not want to boot. It is the wrong default for a domain rule, because it drags the container in and encourages field injection (the test never called the constructor). Mockito can still wrap a fake if you need to verify a call, without Spring: construct the handler with mock(EnrollmentEvents.class). That mock is local to the test. @MockBean is global to the test context and has surprised teams by replacing a bean for a later test class in the same context cache. Prefer constructor fakes. Use @WebMvcTest with an explicit @Import or a mock port when the question is HTTP status mapping.
A wiring test is still valuable and small: @SpringBootTest with a test configuration that supplies the in-memory adapters, calling context.getBean(ConfirmPaymentHandler.class) once, asserting the context loads. If this test is the only test, you do not know whether confirmPayment rejects a draft. If it does not exist, a typo in @Qualifier ships. Keep one context test per application, not one per class.
Lifecycles that affect correctness
@PostConstruct and InitializingBean.afterPropertiesSet run after injection. They are appropriate for validating configuration the constructor already received, not for publishing events or calling remote systems. A @PostConstruct that calls the acquirer makes startup depend on the acquirer and makes tests that load the context depend on it too. Fail fast on missing configuration in the constructor or in a @Bean method. Connect to remote systems on first use or in a ready check you intend.
Disposable beans (@PreDestroy) close clients. A bean that starts a thread in @PostConstruct must stop it in @PreDestroy. Tests that load the context and never close it leak those threads. Another reason the unit test does not load the context.
Clock as a bean is the example this course repeats because Instant.now() inside a policy is an uninjectable dependency. The composition root supplies Clock.systemUTC(). Tests supply Clock.fixed. Passing the clock through the constructor is dependency injection of a JDK type. You do not need an interface for the clock.
Architecture and Design Diagrams
The composition root is the only node that points at concrete adapters and at the handler.
mermaidflowchart TBsubgraph root [Bootstrap composition root]config[EnrollmentConfig]endsubgraph adapters [Adapter instances]store[JdbcEnrollmentStore]events[SpringEnrollmentEvents]endsubgraph app [Application]handler[ConfirmPaymentHandler]endconfig --> handlerconfig --> storeconfig --> eventshandler --> storehandler --> events
The handler's arrows point at types in the application package. The config's arrows point at concrete classes. No arrow runs from the handler back to config. A service locator inside the handler would add that arrow at runtime without it appearing in the constructor, which is why the diagram would lie.
A circular graph, and the break this lesson accepts:
mermaidflowchart LRissuer[CertificateIssuer]query[EnrollmentQuery]store[EnrollmentStore]issuer --> storequery --> store
Before the break, issuer pointed at query and query pointed at issuer. After the break, both point at the store port they actually need. The certificate row is written by the issuer through its own port. The query reads enrollment state through the store. If the UI needs "has certificate," the query reads a column or a certificate repository that does not call the issuer. The issuer does not call the query.
Context startup compared with a unit test:
mermaidsequenceDiagramparticipant Testparticipant Handler as ConfirmPaymentHandlerparticipant Fake as InMemoryStoreTest->>Handler: new Handler(fake, events, clock)Test->>Handler: handle(command)Handler->>Fake: saveFake-->>Handler: okHandler-->>Test: receipt
No SpringApplication in that sequence. A second, separate test starts the context and stops at getBean. Do not merge them.
Technical Explanation
What startup checks
When the context refreshes, Spring creates beans in dependency order, resolves constructors, and fails on:
NoSuchBeanDefinitionException/NoUniqueBeanDefinitionExceptionwrapped inUnsatisfiedDependencyExceptionwhen a constructor parameter has zero or many candidates and no@Primaryor@Qualifier.BeanCurrentlyInCreationExceptionwhen a cycle is detected and early references are disallowed or impossible.- Failures inside
@Beanmethods, which surface as the exception your factory threw. A@Beanmethod that returnsnullis a defect; the container does not treat null as "optional" in modern Spring the way a missing optional injection works. UseObjectProviderorOptionalinjection for optional collaborators. Spring supportsOptional<T>andObjectProvider<T>as injection points. AnOptionalparameter is empty when no bean exists, and it is a single bean when one exists. It does not select among many.
Prototype and request scope do not remove these checks for the beans that are created at startup. A prototype that is never injected is not created, and a missing dependency inside it appears on first getObject(). That delay is a reason to inject prototypes into something the wiring test touches, or to avoid prototype scope.
Qualifiers and the strategy list
java@BeanPricingPolicies pricingPolicies(List<PricingPolicy> policies) {return new PricingPolicies(policies);}
Spring injects all PricingPolicy beans into the list. The PricingPolicies constructor from lesson 17 throws on duplicate codes. Startup fails. That is stricter and more domain-specific than @Qualifier. Qualifiers are for injection points that need one particular bean, such as two RestClient instances aimed at two hosts:
java@Bean@Qualifier("catalog")RestClient catalogClient(RestClient.Builder builder) {return builder.baseUrl(catalogBaseUrl).build();}
RestClient.Builder is a real Spring Framework 6.1 type, and RestClient is the HTTP client this course uses at the adapter edge. The qualifier lives in the adapter configuration. The use case still sees a Catalog port, not a RestClient. Two RestClient beans without qualifiers cause NoUniqueBeanDefinitionException at the injection point that asks for one. The failure is early. Good.
Breaking the locator
An existing method:
javapublic ConfirmationReceipt confirm(UUID id, String key) {EnrollmentStore store = Services.get(EnrollmentStore.class);Clock clock = Services.get(Clock.class);return new ConfirmPaymentHandler(store, Services.get(EnrollmentEvents.class), clock).handle(new ConfirmPayment(id, key));}
This constructs a handler per call, pulls global singletons, and hides the events dependency from every caller. The replacement is one handler bean, dependencies on the constructor, and a controller that receives the handler:
java@RestControllerclass ConfirmPaymentController {private final ConfirmPaymentAction action;ConfirmPaymentController(ConfirmPaymentAction action) {this.action = action;}@PostMapping("/enrollments/{id}/confirm")ConfirmationReceipt confirm(@PathVariable UUID id, @RequestHeader("Idempotency-Key") String key) {return action.confirm(new ConfirmPayment(id, key));}}
ConfirmPaymentAction is the transactional @Service from lesson 17. It receives the handler through its constructor. Nothing calls Services.get. Delete the holder when the last caller is gone. A deprecated Services.get that delegates to the context keeps the locator alive; teammates will use it during an incident. Remove it in the same change. If a job and a controller both confirm payments, both receive ConfirmPaymentAction. That is two driving adapters, lesson 6, sharing one injected use case.
A cycle you should expect to see, and the port that removes it
NoticePipeline wants PreferenceStore. PreferenceStore's update method wants NoticePipeline to send a "preferences changed" notice. That cycle is a domain event waiting to be named. The store depends on PreferenceEvents, a one-method port. The pipeline does not appear in the store's constructor. A listener, injected with the pipeline, sends the notice after commit. Lesson 17's observer is the cycle breaker. Introducing the port is dependency inversion applied at the cycle. @Lazy would let the store call the pipeline during its own update, inside the transaction, and a mail failure would roll back a preference change. The port plus AFTER_COMMIT is the design that matches the failure mode you actually want.
Configuration that must not reach the domain
Secrets and URLs are constructor arguments of adapters, bound with @ConfigurationProperties on a type in the bootstrap or adapter package. Spring Boot's @ConfigurationProperties is the real mechanism: a record or class with the prefix, registered with @EnableConfigurationProperties or as a @Bean. The handler does not read Environment. A handler that calls environment.getProperty("pricing.early-bird.percent") is a locator for configuration, and a test must populate the environment to prove a discount. Pass EarlyBirdWindow into EarlyBirdPricing from a @Bean method that reads the properties once.
java@ConfigurationProperties(prefix = "enrollment.early-bird")public record EarlyBirdProperties(Instant startsAt, Instant endsAt, int percentOff) {}
Validate percentOff in the @Bean method or with validated configuration (@Validated and constraints). An invalid percent fails startup. Failing on the first learner's quote fails a sale.
Practical Implementation or Design Walkthrough
Problem
The enrollment and order modules use a static service locator. Startup has been kept green by allowing circular references. Tests for coupon rules take multiple seconds because they boot the context. The team wants constructor injection, a visible composition root, and unit tests that new up the handler.
Requirements
- No production use case calls
ApplicationContext.getBeanor a staticServicesholder. ConfirmPaymentHandlerandApplyCouponHandlerare constructed with ports and aClock.- Circular references stay disabled. The context starts without
@Lazyon those handlers. - A unit test of
ApplyCouponHandlerdoes not start Spring and does not open a socket. - One
@SpringBootTestproves the production configuration loads with local adapters. - Pricing policies are injected as a list into
PricingPoliciesand nowhere store request state.
Steps
- Add constructors that take every collaborator the locator used to fetch. Make fields
final. - Delete locator calls. If a class still needs a rare optional bean, inject
ObjectProviderand document why it is optional. - Move
newof concrete adapters into@Beanmethods or@Componentadapter classes underadapterandbootstrap. - Draw remaining cycles. Break them with a port or an event, not with
allow-circular-references=true. - Replace
@MockBean-based rule tests with fakes. Keep a slice test for the controller if HTTP mapping is part of the change. - Run the context test with the circular-reference property absent or false.
A fake that is safe to share across tests
javapublic final class InMemoryOrderRepository implements OrderRepository {private final Map<OrderId, Order> byId = new HashMap<>();@Overridepublic synchronized Optional<Order> find(OrderId id, PrincipalId principal) {return Optional.ofNullable(byId.get(id)).filter(order -> order.principalId().equals(principal));}@Overridepublic synchronized void save(Order order, OutboxMessage message) {Order current = byId.get(order.id());if (current != null && current.version() != order.version()) {throw new OrderConflict(order.id());}byId.put(order.id(), order.withIncrementedVersion());messages.add(message);}}
synchronized matters if a test uses two threads, which lesson 18's concurrency test does. A fake that ignores the version teaches the handler nothing about conflict. The fake implements the port's contract, including tenant filtering. A fake that returns any order for any principal hides the missing predicate that the JDBC adapter must include.
The test:
java@Testvoid cancelledOrderRefusesCoupon() {InMemoryOrderRepository repository = new InMemoryOrderRepository();repository.seed(Order.placed(OrderId.random(), principal).cancel());ApplyCouponHandler handler = new ApplyCouponHandler(repository);assertThrows(IllegalTransition.class,() -> handler.handle(new ApplyCoupon(id, principal, "SAVE10", "key-1")));}
assertThrows is JUnit 5. The handler is a plain object. This is the test the locator made inconvenient, and the reason the refactor is worth the diff.
Expected result
Startup fails if a collaborator is missing. A cycle fails the context test instead of surviving behind a flag. Coupon and confirmation rules run in milliseconds. Profiles select adapters in the composition root. The domain packages still import neither Spring nor the locator.
Enterprise Real-World Scenarios
Removing a service locator from a modular monolith
The enrollment, order, and notification modules lived in one Spring Boot process and shared Services, a static ApplicationContextAware holder. Any module could request any bean. The compile-time dependencies between modules were a fiction: notification called Services.get(OrderRepository.class) and the build graph said notification did not depend on orders. A split into separate deployables, which lesson 7's modular monolith discussion prepares you to consider, would fail at runtime on the first lookup.
The refactor is mechanical and political. Constructors first, inside each module, with ports already defined in lessons 6 and 18. The composition root in the application module wires implementations. Module tests are not allowed to import the holder; an ArchUnit rule or a forbidden-import check in the build fails the build on com.techlearningpro.common.Services. During the migration a temporary @Bean method may adapt an old type, but the holder class is deleted when the last call is gone, not left "for scripts." Scripts that need a bean are driving adapters with their own main, not clients of a static map.
Operational effect: missing beans fail at startup in the environment that boots the context, which includes production and the one wiring test. The team watches startup logs for UnsatisfiedDependencyException during the rollout and does not "fix" them by adding @Autowired(required = false) on a payment repository. A required payment repository that is absent is a failed deploy. An optional analytics sender may be ObjectProvider<Analytics> and no-op when empty. The difference is written on the constructor, where it belongs.
Request scope, async dispatch, and a lost principal
A report bean was request-scoped, held the current principal, and was injected into a singleton ReportService via a scoped proxy. The service method annotated @Async submitted work to a pool. The worker thread had no request. The proxy threw ScopeNotActiveException, a real Spring exception when a request or session scope is touched off-request, or it saw an empty security context and generated a report for the wrong scope default. The incident was a data leak across tenants in one case where the code fell back to "all organizations" when the principal was null.
The fix uses injection without request scope on the worker. The controller, which does run on the request thread, reads the principal once and passes PrincipalId into the command. The async handler's constructor takes ReportRepository and Clock, both singletons. The command carries the principal. There is no CurrentUser bean. @Async remains a Spring capability you enable with @EnableAsync, and the executor is a bean you can replace in tests. The lesson's constraint is the data the task closes over: pass values, not scoped proxies, into other threads. After-commit listeners follow the same rule. The event record holds the ids.
Best Practices
- Inject mandatory collaborators through one constructor. Keep the fields
final. - Let the composition root in
bootstrapname concrete classes. Keep that knowledge out ofdomain. - Prefer a
@Beanmethod when the choice of implementation is an architectural decision. Prefer component scan for a family of strategies that all join aList. - Use
@Qualifierwhen several beans share a type and the injection point is specific. Use a domain selector when the choice depends on request data, not when the choice is fixed at startup. - Leave
spring.main.allow-circular-referencesat its default of false. Break cycles with a port or an event. - Pass
Clock, windows, and principals in. Do not readEnvironmentorSecurityContextHolderfrom the domain. The web adapter may read the security context once and put aPrincipalIdon the command.SecurityContextHolderis a thread-local locator. - Test rules with fakes and
new. Test wiring with one context load. Test HTTP with a slice and a fake port. - Treat
ObjectProvideras a deliberate lookup. If every method starts withprovider.getObject(), you have rebuilt a locator. - Do not store request state on singleton beans the container injects.
- Validate configuration in
@Beanor@ConfigurationPropertiesbinding so the process fails startup.
Common Mistakes and Anti-Patterns
Field injection on new code. The constructor is empty, the dependencies are invisible, and tests use the container or reflection. Add a constructor. Keep @Autowired off a single constructor.
Service locator as a migration permanent state. Services.get remains because one job still uses it. New code copies the pattern. Delete the holder. Give the job a constructor.
allow-circular-references=true in production configuration. The cycle that Boot 2.6 stopped tolerating is back. Startup no longer reports it. Remove the flag and fix the arrows.
@Lazy on both sides of a cycle. The context starts. The first request recurses or observes a half-initialized collaborator. Replace the back-edge with a port.
Injecting ApplicationContext into a service "for flexibility." That is a locator with a larger type. Inject the three ports the service calls.
Prototype scope expected to mean per call, injected once into a singleton. All calls share one instance. Use ObjectProvider and getObject() per call, or stop using prototype and create the object with new in the method if it has no dependencies that the container must supply.
Request-scoped beans on @Async or AFTER_COMMIT threads. ScopeNotActiveException or a wrong principal. Copy the ids onto the command or the event.
A context test per class, and no constructor test. The suite is slow and the rule is untested when the fake would have encoded a version conflict. Invert the ratio.
@Primary on a test double that component scan picks up in production. The double is a @Profile("test") or lives in src/test. It is not a @Component in src/main with @Primary.
Troubleshooting and Design Review
Symptom: UnsatisfiedDependencyException naming ConfirmPaymentHandler and Clock. No Clock bean exists. Add the @Bean in the composition root. Do not switch the handler to Clock.systemUTC() internally; the test will lose control of time.
Symptom: NoUniqueBeanDefinitionException for RestClient or PricingPolicy. Several beans match. If you meant all policies, inject List<PricingPolicy>. If you meant one HTTP client, add @Qualifier at the injection point and on the @Bean. If a test double and a real adapter are both active, your profiles overlap.
Symptom: BeanCurrentlyInCreationException after a small change. A new constructor parameter closed a cycle. Read the bean names in the message; they are the cycle. Break the back-edge. Do not set the allow-circular flag in the test to make it pass.
Symptom: unit test NullPointerException on a field the production context populates. The class still uses field injection. The test called new. Convert to constructor injection and the compiler will list what the test must pass.
Symptom: works in the controller, ScopeNotActiveException in the listener. A request-scoped dependency was invoked off the request thread. Put the needed ids on the event.
Symptom: production uses the in-memory repository. The in-memory class is annotated @Component and @Primary on the main classpath. Move it to test sources or guard it with @Profile("local") and make sure the production profile does not include local.
Design review. Are all mandatory dependencies constructor parameters and final? Does any use case call getBean or a static holder? Is the circular-reference flag unset? Does any singleton hold request data? Are test doubles impossible to scan in production? Does the domain import org.springframework? Is there one wiring test and many constructor tests? Can you name the composition root in one package?
Interview Questions and Answers
Q1. What is the difference between inversion of control, dependency injection, and dependency inversion?
Inversion of control means a framework calls your components instead of your main calling every library step. Dependency injection means collaborators are passed in, usually through the constructor, rather than constructed or looked up inside the class. Dependency inversion means source code depends on abstractions owned by the high-level policy, which is lesson 4 and the port direction in lesson 6. You can inject a concrete class and fail dependency inversion. You can depend on an interface and still look it up through a locator, which fails the injection test. The three line up when a handler depends on a port and the composition root passes the adapter in.
Q2. Why does this course prefer constructor injection over field injection?
The constructor lists required collaborators, allows final fields, and lets a unit test call new with fakes. Spring Framework has recommended constructor injection for mandatory dependencies for years, and since 4.3 a single constructor is used without @Autowired. Field injection hides the list and couples tests to the container or to reflection. Setter injection leaves the object mutable and possibly half built.
Q3. What is a composition root, and where does it live in a Spring Boot modular monolith?
It is the part of the program that instantiates concrete classes and connects them. In Spring Boot that is @SpringBootApplication and @Configuration @Bean methods, placed in a bootstrap package that is allowed to see adapters. Domain and application packages do not instantiate JDBC repositories or read profiles. If two modules each start their own context, each has a root. A static holder shared across modules is an undeclared root.
Q4. A singleton is injected with a prototype-scoped bean. How many prototypes are created?
One, when the singleton is created, if the prototype is a normal constructor dependency. Prototype scope creates a new instance per request to the container. The singleton's constructor asks once. To get a new prototype per operation, inject ObjectProvider and call getObject() inside the method, or construct the object with new if the container has nothing to contribute.
Q5. Why did Spring Boot 2.6 disable circular references by default, and why is turning them back on a poor fix?
Cycles are usually design errors, and the old container behavior hid them behind early references that only worked for setter and field injection. Boot 2.6 fails startup with BeanCurrentlyInCreationException unless spring.main.allow-circular-references is true. Enabling the flag restores the hidden cycle. Break the cycle by depending on a port, by publishing an event, or by moving a responsibility so one side does not need the other.
Q6. When is ApplicationContext.getBean acceptable?
Inside genuine bootstrap code that is already the composition root, and inside a thin framework integration you do not control. It is not acceptable in a use case, an entity, or a repository implementation that "sometimes" needs another service. Those dependencies belong on the constructor so startup and tests see them. ObjectProvider is the explicit, typed alternative when resolution must be deferred.
Q7. How do you test a handler that used to call a service locator?
Give it a constructor. Pass an in-memory fake that honors the port's contract, including conflicts and tenant filters. Assert with JUnit. Add one context test that loads the configuration and retrieves the handler. Do not replace the locator with @MockBean for the rule test. @WebMvcTest remains appropriate for the controller, with the action or handler stubbed at the slice boundary.
Q8. How does List<PricingPolicy> injection interact with @Primary?
A List<PricingPolicy> parameter receives every matching bean, and @Primary does not reduce that list to one element. A single PricingPolicy parameter receives the primary bean if one is marked, or fails if several exist and none is primary or qualified. The selector should take the list. A class that accidentally takes one PricingPolicy will not see the other policies and will not fail if a primary exists. Type the dependency as the selector.
Q9. A listener after commit cannot see the request-scoped current user. What do you change?
Stop depending on request scope off the request thread. The driving adapter copies PrincipalId and any other request facts onto the command or the domain event. The listener's constructor takes singleton ports. Scoped proxies and SecurityContextHolder are thread-bound. TransactionPhase.AFTER_COMMIT can run on the same thread after the request attributes are still present in simple cases and will not in async dispatch. Do not rely on that accident.
Q10. How do you keep secrets and feature thresholds out of the domain while still injecting them?
Bind @ConfigurationProperties in the bootstrap or adapter package. A @Bean method builds a domain value such as EarlyBirdWindow or a gateway client that already contains the base URL and calls the port. The domain receives the window or the port. It does not receive Environment. Invalid values fail in the @Bean method at startup.
Q11. What failure mode does @Autowired(required = false) create on a repository?
The field stays null and the context starts. The first request throws NullPointerException, or a branch skips persistence. For a required port, omit the attribute so startup fails. For a truly optional integration, inject Optional or ObjectProvider and write the absent case as an explicit no-op the test covers.
Q12. How does dependency injection support the testability scenario from lesson 2 without a container?
The scenario wants a rule tested without the mechanisms it does not own. Constructor injection lets the test pass a fake repository and a fake gateway. The container is one implementation of "someone passes them in." If the test must boot Spring, JDBC, and a network stub to assert that a cancelled order refuses a coupon, the object still reaches out for its collaborators. Injection into a constructor is the design that makes the cheap test possible. The wiring test separately guards the composition root.
Hands-On Exercises
Exercise 1: Replace the locator
Problem. Refactor a handler that uses Services.get for a repository, a clock, and an event port into constructor injection.
Tasks.
- List every
Services.getcall and the type it retrieves. That list is the constructor. - Provide a
@Configurationclass that wires the JDBC adapter,Clock.systemUTC(), and the Spring event adapter. - Delete the locator calls in this handler. Describe what would have to be true before you delete the holder class entirely.
- Write a JUnit test that uses an in-memory repository and a fixed clock and does not start Spring.
- State how startup behaves if the clock bean is removed, including the exception type you expect.
Rubric. Requirements 15%. Constructor and composition root 25%. Unit test without Spring 20%. Startup failure mode and leftover locator risk 20%. Trade-offs 10%. Clarity 10%.
Exercise 2: Break a circular dependency
Problem. CertificateIssuer depends on EnrollmentQuery and EnrollmentQuery depends on CertificateIssuer. Circular references are disabled.
Tasks.
- Draw the cycle and write the question each side is actually asking.
- Introduce a port or an event so neither concrete class constructs the other. Do not use
@Lazyorallow-circular-references. - Show the constructors after the change.
- Explain whether certificate issuance is inside the enrollment transaction or after commit, and why that choice avoids a new runtime cycle of calls.
- Add a test that the query can answer enrollment state with a fake store and no issuer.
Rubric. Same weights. A solution that starts the context only because the flag was re-enabled does not pass.
Lesson Summary
Inversion of control is the container calling your objects. Dependency injection is those objects receiving collaborators from outside. Dependency inversion is the handler depending on a port it owns, which lessons 4 through 6 required and this lesson wires. Constructor injection, a bootstrap composition root, and singleton beans that hold no request state are the default. ObjectProvider and qualifiers are the explicit escapes. Service locators and circular references fail startup or fail tests for a reason; silencing them with a flag or @Lazy keeps the bad graph. Fakes passed to new test the rules. One context test proves the graph. The next lesson uses these habits on a parking-lot design: allocation strategies injected as policies, a repository for tickets, and a concurrency story the container cannot provide for you.
Further Reading
- Spring Framework reference, Core: the IoC container, dependencies, constructor resolution,
@Qualifier,@Primary,ObjectProvider, and bean scopes. - Spring Boot 2.6 release notes: circular references prohibited by default (
spring.main.allow-circular-references). - Spring Framework reference, Testing:
@SpringBootTest,@WebMvcTest, and the difference between slicing the container and constructing objects. - Martin Fowler, "Inversion of Control Containers and the Dependency Injection pattern."
- SOLID Principles in Practice and Hexagonal Architecture and Ports & Adapters.
- Jakarta Inject
Provider, if you keep a lookup type at the edge of the application without importing Spring into a portable module.