Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
Lesson 31 · Web and Spring
Dependency Injection and Inversion of Control: The Idea Behind Spring
Understand dependency injection and inversion of control from first principles: wire objects by hand, build a tiny container, then see the real Spring container create beans, resolve ambiguity, manage scopes and lifecycle, and fail with the errors you will actually meet.
For
Learners who understand interfaces and testing and are about to use Spring Boot, and who want to know what the framework is actually doing.
Level
Before you start
Interfaces and composition (article 6), testing with fakes (article 25) and the layered Expense Tracker (articles 26 and 29). Verified with Spring Framework 7.0.9 (the version managed by Spring Boot 4.1.1) on JDK 21 and JDK 25.
On this page
Key takeaways
- Dependency injection means an object receives the collaborators it needs instead of creating them. It makes classes testable and replaceable, and constructor injection is the preferred form.
- Inversion of control means a framework, not your code, creates the objects, connects them and manages their lifecycle. A container is a program that does this.
- Spring's container (the ApplicationContext) holds beans. You declare them with annotations such as @Component, @Service and @Repository, or with @Bean methods for classes you cannot annotate, and Spring injects them by type.
- Beans are singletons by default, so every user of the application shares one instance. Mutable fields in a bean are shared mutable state and need the care from the concurrency articles.
- Startup errors such as NoUniqueBeanDefinitionException, NoSuchBeanDefinitionException and BeanCurrentlyInCreationException are readable once you know what the container was trying to do.
Project 1 and Project 2 already used the central idea of this article without naming it. ExpenseService did not create its own repository. The Main class created a repository and handed it in, so the service never knew whether it was talking to a file, a database or a test fake. That is dependency injection. This article explains it, shows what a framework adds, and introduces the Spring container, so that Spring Boot, in the next article, is not magic.
What you will learn
#- What a dependency is, and why
newinside a class creates coupling. - Dependency injection and inversion of control, shown by hand.
- How Spring's container creates and connects beans.
- Bean scopes and lifecycle, and the errors you will meet.
The problem: classes that build their own collaborators
#A dependency is anything a class needs to do its job: a repository, a mail sender, a clock. The simplest way to get one is to create it inside the class:
class TightReminder { private final Mailer mailer = new SmtpMailer(); // this class decides, forever}This works, and it causes three problems:
- It cannot be tested in isolation. Every test of
TightRemindersends real email, because there is no way to substitute a differentMailer. - It cannot be reconfigured. Using a different mailer in production, in a demo, or for a new customer means editing the class.
- It hides the dependency. Nothing in the class's signature says that it needs a mailer.
The fix is the principle from article 6: depend on an interface, and let someone else supply the implementation.
Dependency injection
#Dependency injection (DI) means an object receives its dependencies from outside instead of creating them. There are three ways to receive them:
Form | Example | Verdict |
|---|---|---|
Constructor injection |
| Preferred. The dependency is required and visible, the field can be |
Setter injection |
| For genuinely optional or changeable dependencies. The object can exist in an invalid state |
Field injection |
| Avoid. The field cannot be |
Dependency injection is a pattern, not a framework: you can do it in plain Java, and you have been doing so. The program below shows the whole idea with no libraries. It also builds a tiny container of about thirty lines, to show what a framework like Spring actually does.
// File: Main.javaimport java.util.ArrayDeque;import java.util.ArrayList;import java.util.Deque;import java.util.HashMap;import java.util.List;import java.util.Map;import java.util.function.Supplier;public class Main { interface Mailer { void send(String to, String text); } static class SmtpMailer implements Mailer { SmtpMailer() { System.out.println(" (creating SmtpMailer)"); } public void send(String to, String text) { System.out.println(" REAL EMAIL to " + to + ": " + text); // a side effect we cannot afford in a test } } static class RecordingMailer implements Mailer { final List<String> sent = new ArrayList<>(); public void send(String to, String text) { sent.add(to + ": " + text); } } /** Tightly coupled: it chooses its own collaborator, so no one else can. */ static class TightReminder { private final Mailer mailer = new SmtpMailer(); void remind(String who) { mailer.send(who, "please pay your bill"); } } /** Loosely coupled: the collaborator is handed in. */ static class Reminder { private final Mailer mailer; Reminder(Mailer mailer) { System.out.println(" (creating Reminder)"); this.mailer = mailer; } void remind(String who) { mailer.send(who, "please pay your bill"); } } /** A very small container: it knows how to build each type, and builds each one once, on demand. */ static class Container { private final Map<Class<?>, Supplier<?>> recipes = new HashMap<>(); private final Map<Class<?>, Object> singletons = new HashMap<>(); private final Deque<String> beingCreated = new ArrayDeque<>(); <T> void register(Class<T> type, Supplier<? extends T> recipe) { recipes.put(type, recipe); } @SuppressWarnings("unchecked") <T> T get(Class<T> type) { if (singletons.containsKey(type)) { return (T) singletons.get(type); } if (beingCreated.contains(type.getSimpleName())) { throw new IllegalStateException("circular dependency: " + beingCreated + " -> " + type.getSimpleName()); } Supplier<?> recipe = recipes.get(type); if (recipe == null) { throw new IllegalStateException("no recipe for " + type.getSimpleName()); } beingCreated.addLast(type.getSimpleName()); try { Object created = recipe.get(); singletons.put(type, created); return (T) created; } finally { beingCreated.removeLast(); } } } interface A { } interface B { } public static void main(String[] args) { System.out.println("1. Tight coupling: testing it sends a real email"); new TightReminder().remind("ada@example.com"); System.out.println("2. Constructor injection: the test chooses a recording mailer"); RecordingMailer recorder = new RecordingMailer(); new Reminder(recorder).remind("ada@example.com"); System.out.println(" recorded instead: " + recorder.sent); System.out.println("3. A tiny container wires the real objects (the composition root)"); Container container = new Container(); container.register(Mailer.class, SmtpMailer::new); container.register(Reminder.class, () -> new Reminder(container.get(Mailer.class))); Reminder reminder = container.get(Reminder.class); reminder.remind("grace@example.com"); System.out.println(" asked again, same instance: " + (container.get(Reminder.class) == reminder)); System.out.println("4. The same container notices a missing recipe and a cycle"); container.register(A.class, () -> { container.get(B.class); return new A() { }; }); container.register(B.class, () -> { container.get(A.class); return new B() { }; }); try { container.get(A.class); } catch (IllegalStateException e) { System.out.println(" " + e.getMessage()); } try { new Container().get(Mailer.class); } catch (IllegalStateException e) { System.out.println(" " + e.getMessage()); } }}1. Tight coupling: testing it sends a real email (creating SmtpMailer) REAL EMAIL to ada@example.com: please pay your bill2. Constructor injection: the test chooses a recording mailer (creating Reminder) recorded instead: [ada@example.com: please pay your bill]3. A tiny container wires the real objects (the composition root) (creating SmtpMailer) (creating Reminder) REAL EMAIL to grace@example.com: please pay your bill asked again, same instance: true4. The same container notices a missing recipe and a cycle circular dependency: [A, B] -> A no recipe for MailerReading the four parts:
- Tight coupling. Calling
remindonTightRemindersends a "REAL EMAIL", and the constructor ofSmtpMailerran as a side effect. There is no way to avoid it in a test. - Constructor injection.
Reminderreceives aMailer. The "test" passes aRecordingMailer, which stores messages in a list, and no email is sent. The class under test is identical in production and in the test. Only what is passed in differs. This is exactly the fake-versus-real swap you used for the repositories. - A container. Somewhere, something must decide which implementations to use and connect them. That place is the composition root, the one spot near
mainthat knows the concrete classes. TheContainerhere stores a recipe for each type, builds each object on first request, reuses it afterwards (a singleton), and resolves a recipe's own dependencies by asking the container again. Notice the order of the "creating" messages:SmtpMailerwas created while buildingReminder, because the recipe forReminderasked for aMailer. - Errors a container can detect. Because it tracks what it is building, it can report a circular dependency (A needs B, B needs A) and a missing recipe clearly instead of failing mysteriously.
Inversion of control
#In ordinary code, your code is in charge: it decides which objects to create and calls them. With a container, control is inverted: the container creates your objects, connects them, and calls their lifecycle methods, and your code is called by the framework. That is inversion of control (IoC), sometimes summarised as "don't call us, we'll call you". Dependency injection is the most common way of achieving it.
Spring's container does the same job as the thirty-line one above, with far more features: it finds your classes by scanning, understands annotations, manages scopes and lifecycles, wraps beans in proxies for features such as transactions, and reads configuration. Everything else in Spring is built on top of it.
The Spring container
#In Spring, the container is an ApplicationContext. The objects it manages are called beans. You tell Spring about beans in two ways.
Stereotype annotations on your own classes. Each marks a class as a bean to create, and the names describe its role:
Annotation | Meaning |
|---|---|
| A generic bean |
| A class holding business logic |
| A class doing data access |
| A class handling web requests (the next articles) |
| A class whose |
(@Service and @Repository behave like @Component, and exist to document intent. @Repository also translates database exceptions in Spring's data access support.)
@Bean methods inside a @Configuration class, for objects you cannot annotate because you do not own the class (a Clock, a DataSource, a library client). The method is the recipe, and its return value becomes the bean.
By default Spring injects by type: a constructor parameter of type ExpenseRepository receives the one bean that is an ExpenseRepository. In a Spring application with a single constructor you do not even need @Autowired.
The project for this article depends on spring-context alone, to keep the demonstration to the container with no web server and no auto-configuration. The jakarta.annotation-api dependency provides @PostConstruct and @PreDestroy, which a Spring Boot starter would normally bring in for you:
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>di-demo</artifactId> <version>1.0.0</version> <properties> <java.version>21</java.version> </properties> <dependencies> <!-- The Spring container on its own, with no web server and no auto-configuration --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> </dependency> <!-- @PostConstruct and @PreDestroy. A Spring Boot starter would bring this in for you --> <dependency> <groupId>jakarta.annotation</groupId> <artifactId>jakarta.annotation-api</artifactId> </dependency> </dependencies></project>(The parent POM, spring-boot-starter-parent, supplies the versions. That is the BOM idea from article 23, and why the two dependencies have no <version>.)
The program below runs six small demonstrations, each with its own container, including the failures. Package-private classes in one file keep the example compact, but in a real project each class has its own file:
// File: src/main/java/com/example/di/SpringDemos.javapackage com.example.di;import jakarta.annotation.PostConstruct;import jakarta.annotation.PreDestroy;import java.time.Clock;import java.time.Instant;import java.time.ZoneOffset;import java.util.ArrayList;import java.util.List;import org.springframework.beans.factory.annotation.Qualifier;import org.springframework.context.annotation.AnnotationConfigApplicationContext;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.context.annotation.Primary;import org.springframework.context.annotation.Scope;import org.springframework.stereotype.Component;import org.springframework.stereotype.Repository;import org.springframework.stereotype.Service;// ------------------------------------------------------------------ the application's classesinterface PriceFormatter { String format(long cents);}@Componentclass DollarFormatter implements PriceFormatter { public String format(long cents) { return String.format("$%d.%02d", cents / 100, cents % 100); }}@Componentclass EuroFormatter implements PriceFormatter { public String format(long cents) { return String.format("%d.%02d EUR", cents / 100, cents % 100); }}interface ExpenseRepository { List<Long> amounts();}@Repositoryclass InMemoryExpenseRepository implements ExpenseRepository { public List<Long> amounts() { return List.of(1250L, 4500L, 320L); }}@Serviceclass ReportService { private final ExpenseRepository repository; private final PriceFormatter formatter; ReportService(ExpenseRepository repository, PriceFormatter formatter) { // constructor injection this.repository = repository; this.formatter = formatter; System.out.println(" ReportService created with " + repository.getClass().getSimpleName() + " and " + formatter.getClass().getSimpleName()); } String total() { long sum = repository.amounts().stream().mapToLong(Long::longValue).sum(); return formatter.format(sum); }}// ------------------------------------------------------------------ classes used by the demonstrations@Serviceclass ChosenFormatterReport { private final PriceFormatter formatter; ChosenFormatterReport(@Qualifier("euroFormatter") PriceFormatter formatter) { // choose by bean name this.formatter = formatter; } String sample() { return formatter.format(1250); }}@Primary@Componentclass PreferredDollarFormatter implements PriceFormatter { public String format(long cents) { return String.format("$%d.%02d", cents / 100, cents % 100); }}@Serviceclass DefaultFormatterReport { private final PriceFormatter formatter; DefaultFormatterReport(PriceFormatter formatter) { this.formatter = formatter; } String sample() { return formatter.format(1250); }}@Componentclass SharedCounter { // singleton: the default scope}@Component@Scope("prototype")class FreshObject { // a new instance for every request to the container}@Configurationclass ClockConfig { @Bean Clock fixedClock() { // a bean for a class we cannot annotate return Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneOffset.UTC); }}@Componentclass Connection { Connection() { System.out.println(" 1 constructor runs"); } @PostConstruct void open() { System.out.println(" 2 @PostConstruct runs: the bean is fully wired, so it may start work"); } @PreDestroy void close() { System.out.println(" 4 @PreDestroy runs: release resources before the container goes away"); }}@Componentclass Chicken { Chicken(Egg egg) { }}@Componentclass Egg { Egg(Chicken chicken) { }}// ------------------------------------------------------------------ the demonstrationspublic class SpringDemos { /** Spring wraps the real problem in several layers, so print the innermost cause. */ static void firstLine(String label, Exception e) { Throwable root = e; while (root.getCause() != null) { root = root.getCause(); } String message = root.getMessage().lines().findFirst().orElse(""); System.out.println(" " + label + ": " + root.getClass().getSimpleName()); System.out.println(" " + message); } static void basicWiring() { System.out.println("1. The container creates and connects the objects"); try (var context = new AnnotationConfigApplicationContext( DollarFormatter.class, InMemoryExpenseRepository.class, ReportService.class)) { System.out.println(" total = " + context.getBean(ReportService.class).total()); List<String> names = new ArrayList<>(List.of(context.getBeanDefinitionNames())); names.removeIf(n -> n.startsWith("org.springframework")); System.out.println(" bean names: " + names.stream().sorted().toList()); } } static void ambiguity() { System.out.println("2. Two beans of the same type"); try (var context = new AnnotationConfigApplicationContext( DollarFormatter.class, EuroFormatter.class, DefaultFormatterReport.class)) { System.out.println(" unexpectedly started"); } catch (Exception e) { firstLine("without a choice", e); } try (var context = new AnnotationConfigApplicationContext( DollarFormatter.class, EuroFormatter.class, ChosenFormatterReport.class)) { System.out.println(" with @Qualifier(\"euroFormatter\"): " + context.getBean(ChosenFormatterReport.class).sample()); } try (var context = new AnnotationConfigApplicationContext( PreferredDollarFormatter.class, EuroFormatter.class, DefaultFormatterReport.class)) { System.out.println(" with @Primary on one of them: " + context.getBean(DefaultFormatterReport.class).sample()); } } static void missingBean() { System.out.println("3. A dependency nobody provides"); try (var context = new AnnotationConfigApplicationContext(ReportService.class, DollarFormatter.class)) { System.out.println(" unexpectedly started"); } catch (Exception e) { firstLine("no ExpenseRepository bean", e); } } static void scopesAndBeanMethods() { System.out.println("4. Scopes, and a bean built by a method"); try (var context = new AnnotationConfigApplicationContext( SharedCounter.class, FreshObject.class, ClockConfig.class)) { System.out.println(" singleton, asked twice, same instance: " + (context.getBean(SharedCounter.class) == context.getBean(SharedCounter.class))); System.out.println(" prototype, asked twice, same instance: " + (context.getBean(FreshObject.class) == context.getBean(FreshObject.class))); System.out.println(" @Bean method result: " + context.getBean(Clock.class).instant()); } } static void lifecycle() { System.out.println("5. The lifecycle of a bean"); try (var context = new AnnotationConfigApplicationContext(Connection.class)) { System.out.println(" 3 the application is running"); } } static void circular() { System.out.println("6. Two beans that need each other through their constructors"); try (var context = new AnnotationConfigApplicationContext(Chicken.class, Egg.class)) { System.out.println(" unexpectedly started"); } catch (Exception e) { firstLine("startup", e); } } public static void main(String[] args) { basicWiring(); ambiguity(); missingBean(); scopesAndBeanMethods(); lifecycle(); circular(); }}Real output, identical on JDK 21 and JDK 25 (this demo uses AnnotationConfigApplicationContext, which builds a container from the classes you give it; Spring itself also prints some log lines to the error stream, which are not shown):
1. The container creates and connects the objects ReportService created with InMemoryExpenseRepository and DollarFormatter total = $60.70 bean names: [dollarFormatter, inMemoryExpenseRepository, reportService]2. Two beans of the same type without a choice: NoUniqueBeanDefinitionException No qualifying bean of type 'com.example.di.PriceFormatter' available: expected single matching bean but found 2: dollarFormatter,euroFormatter with @Qualifier("euroFormatter"): 12.50 EUR with @Primary on one of them: $12.503. A dependency nobody provides no ExpenseRepository bean: NoSuchBeanDefinitionException No qualifying bean of type 'com.example.di.ExpenseRepository' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}4. Scopes, and a bean built by a method singleton, asked twice, same instance: true prototype, asked twice, same instance: false @Bean method result: 2026-03-15T10:00:00Z5. The lifecycle of a bean 1 constructor runs 2 @PostConstruct runs: the bean is fully wired, so it may start work 3 the application is running 4 @PreDestroy runs: release resources before the container goes away6. Two beans that need each other through their constructors startup: BeanCurrentlyInCreationException Error creating bean with name 'chicken': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?1. Creation and naming
#The container created InMemoryExpenseRepository, DollarFormatter and ReportService, working out the order itself: ReportService's constructor needs an ExpenseRepository and a PriceFormatter, so those had to exist first. It passed in the only bean of each type. By default, a bean is named after its class with the first letter in lower case (reportService). You never wrote a new for any of these.
2. Two candidates
#With two PriceFormatter beans and a constructor that asks for one, Spring refuses to guess: NoUniqueBeanDefinitionException ... expected single matching bean but found 2: dollarFormatter,euroFormatter. The message lists the candidates, which is the information you need. There are two standard fixes:
@Primaryon one implementation makes it the default whenever there is a choice.@Qualifier("euroFormatter")on the parameter chooses a specific bean by name, here getting the euro formatter even though another one exists.
3. A missing bean
#When nothing provides an ExpenseRepository, the context fails at startup with NoSuchBeanDefinitionException ... expected at least 1 bean which qualifies as autowire candidate. That is a feature: a wiring mistake stops the application immediately and visibly, not at 3 a.m. when a user first hits that code path. The error message names the type that was needed, and the surrounding (longer) message names the bean that needed it. The usual causes are a missing annotation, a class outside the scanned packages, or a typo in a configuration class.
Notice that Spring wraps the real problem in several layers of exceptions (UnsatisfiedDependencyException around NoSuchBeanDefinitionException). The demonstration prints the innermost cause, which is what to look for in a long stack trace as well: read to the bottom, to the last Caused by, as in article 22.
4. Scopes and @Bean methods
#The scope of a bean says how many instances exist:
Scope | Instances | Use |
|---|---|---|
| One per container | Almost everything: services, repositories, controllers |
| A new one each time it is requested | Stateful objects that must not be shared |
| One per HTTP request or session | Rarely needed |
The singleton was the same instance when asked twice (true), the prototype was not (false). The @Bean method produced a bean for a class that Spring does not own, here a fixed Clock, which is precisely the pattern for tests that need a controlled time (article 25).
The consequence of singletons is important. Every request to your web application is handled concurrently by many threads, and they all use the same service object. A singleton bean must therefore be stateless or thread-safe: no mutable instance fields that hold per-request data. A field such as private int requestCount incremented in a service is a data race (article 19), and a classic production bug in Spring applications. Keep state in method parameters, local variables, or the database.
5. The lifecycle
#Spring controls when a bean is born and when it dies: the constructor runs, then the container finishes injecting, then @PostConstruct runs (use it to start something that needs the injected dependencies), the application runs, and on shutdown @PreDestroy runs (use it to release resources). The numbers in the output show that order. Prefer constructor injection for dependencies, because in @PostConstruct the object is already fully built.
6. Circular dependencies
#Chicken needs an Egg and Egg needs a Chicken in their constructors, so neither can be built first. The container reports BeanCurrentlyInCreationException ... Is there an unresolvable circular reference? and refuses to start. A cycle is a design smell: two classes that cannot exist without each other are really one concept, or both depend on something missing. Fix it by redesigning: extract the shared behaviour into a third class that both use, merge the two classes, or use an event or callback instead of a direct reference. Avoid the escape hatches that make cycles "work" (setter injection, @Lazy, allowing circular references in configuration), because they hide the problem and make initialisation order unpredictable.
From Spring to Spring Boot
#Everything above is the Spring Framework. Spring Boot is built on it and removes the setup work. You will meet it in the next article, but the key point is that Boot did not replace the container. @SpringBootApplication combines @SpringBootConfiguration (a @Configuration class), @EnableAutoConfiguration and @ComponentScan: it scans your packages for the stereotype annotations above, and then auto-configures beans (a web server, a JSON mapper, a connection pool) from the libraries it finds on the classpath. The beans are ordinary Spring beans, and you can inject them like your own.
Why this makes testing easy
#Because ReportService receives its collaborators through its constructor, a unit test needs no Spring at all:
ReportService service = new ReportService(fakeRepository, new DollarFormatter());This is the biggest practical payoff of constructor injection, and the reason this course has used it since Project 1. Tests that start a Spring container (slice tests, article 37) are valuable but slower, and the fast, plain unit tests should be the majority.
Older style. Older Spring applications describe beans in XML files (
applicationContext.xml) loaded byClassPathXmlApplicationContext, and commonly use field injection with@Autowiredon private fields. You will meet both in existing code bases. Spring 6 and later (and Spring Boot 3 and later) also moved from thejavax.*packages tojakarta.*, because Java EE became Jakarta EE, so old tutorials that importjavax.annotation.PostConstructorjavax.persistenceneedjakartain their place.
Common mistakes
#- Field injection (
@Autowiredon a field), which hides dependencies and blocks plain unit tests. - Calling
newon a class that should be a bean, so Spring's features (injection, transactions) are not applied to that object. - Mutable state in a singleton bean, which is shared between all threads.
- A class outside the scanned packages and wondering why it is not a bean.
- Two implementations and no
@Primaryor@Qualifier. - Circular dependencies "fixed" with
@Lazyor setters instead of redesign. - Putting business logic in
@PostConstructor heavy work in constructors, which slows and complicates startup. - Using a container in a unit test when a plain constructor call would do.
- Reading only the top of a startup stack trace. The real cause is in the last
Caused by.
Exercises
#Exercise 1
Remove the coupling
#class InvoiceService { private final TaxCalculator tax = new GermanTaxCalculator(); } Change it so a test can supply its own calculator and show the one line of the composition root.
Exercise 2: Read the error
#A startup fails with No qualifying bean of type 'com.shop.PaymentGateway' available: expected single matching bean but found 2: stripeGateway,paypalGateway. What happened, and give two ways to fix it.
Exercise 3: The hidden race
#A @Service has the field private int ordersToday = 0; and a method that does ordersToday++ for every request. What is wrong and what would you do instead?
Exercise 4: Choose the scope
#A bean holds a StringBuilder used to assemble each report. Which scope, and why is singleton wrong?
Exercise 5: Break the cycle
#OrderService calls CustomerService and CustomerService calls OrderService, both through constructors, and the application fails to start. Describe two redesigns.
Show solutionHide solution
class InvoiceService { private final TaxCalculator tax; InvoiceService(TaxCalculator tax) { this.tax = tax; }}A test passes a fake calculator. The composition root (or Spring) supplies the real one: new InvoiceService(new GermanTaxCalculator()). With Spring, GermanTaxCalculator is annotated @Component and the constructor parameter is filled automatically.
Exercise 2
Show solutionHide solution
Two beans implement PaymentGateway, and an injection point asks for just one, so Spring cannot choose. Fix 1: mark the preferred implementation @Primary. Fix 2: name the one you want at the injection point with @Qualifier("stripeGateway"). A third option is to inject a List<PaymentGateway> (or a Map<String, PaymentGateway>) if the code genuinely needs all of them.
Exercise 3
Show solutionHide solution
The service is a singleton shared by all request threads, so ordersToday++ is an unsynchronised read-modify-write, a race condition: counts are lost under load. Do not keep per-application state in a service field. Use an AtomicInteger or LongAdder if an in-memory count really is wanted, but for a count that matters, store it in the database or a metrics system, which also survives restarts and works with several application instances.
Exercise 4
Show solutionHide solution
prototype (or, better, a local variable or a new object created inside the method). A singleton would be shared by every concurrent request, so reports would be mixed together and corrupted. The best design avoids the shared StringBuilder altogether, by creating one inside the method that builds the report.
Exercise 5
Show solutionHide solution
First: extract the behaviour both need into a third class, OrderCustomerLinker for example, that depends on both services or on repositories, so the dependency becomes a tree and not a loop. Second: remove one direction by moving the call, for example CustomerService publishes an event ("customer closed") that OrderService listens for, so CustomerService no longer holds a reference to OrderService. A third option is merging the two classes if they are really one concept. Do not paper over it with @Lazy or setter injection.
Where to go next
#- Next: Spring Boot Foundations shows starters, auto-configuration and configuration properties, built on the container you have just seen.
- Then: Building a REST API with Spring Boot uses
@RestController,@Serviceand@Repositorybeans to build the API designed in the last article. - For testing with and without the container: Testing Spring Boot Applications.
Sources & further reading
- Technology
- Software Engineering
- #dependency injection
- #inversion of control
- #Java
- #Java backend
- #Java interview
- #JPA
- #learn Java
- #Spring
- #Spring beans
- #Spring Boot
- #SQL
Connected lessons
References
- Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- Debugging: Errors, Stack Traces, the Debugger and Logging
Show all 9 →
- Interfaces, Polymorphism and Composition
- Concurrency Fundamentals: Threads, Races and Synchronisation
- HTTP, JSON and REST APIs: How Web Backends Talk
- Building a REST API with Spring Boot: Controllers, Services, DTOs, Validation and Errors
- Spring Boot Foundations: Starters, Auto-Configuration, Properties and Profiles
- Maven: What It Does and How to Use It
Linked from 3 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
Heaps and Priority Queues
Guide · Technology
Dynamic Programming Practice: From 1D to 2D
Course · Technology
DSA From Zero to Job-Ready: A Practical Guide with Java

