In the previous lesson we wrote public StationService(StationRepository stationRepository) and Spring found the right implementation all by itself. That capability —building an object by handing it its collaborators ready-made— is the heart of the container and the reason Spring exists. In this lesson we will take the mechanism apart: what inversion of control is, what real problem it solves, what the three injection styles are and why only one is defensible, how Spring resolves a type when several implementations are candidates, how to inject all of them at once into a List or a Map, what to do when a dependency is optional and how to get out of a circular dependency without resorting to patches. All of it while building the first piece of the Ribalta network's fare system.
Contents
- Inversion of control and dependency injection
- Before and after: coupling versus injection
- The three types of injection
- Why constructor injection is the only advisable one
- How Spring resolves dependencies
- Several implementations of the same type:
@Primaryand@Qualifier - Injecting every implementation:
ListandMap - Optional dependencies:
OptionalandObjectProvider - Circular dependencies
- Common Mistakes and Tips
- Exercises
- Inversion of control and dependency injection
These are two related but distinct concepts, and it is best not to mix them up.
Inversion of control (IoC) is a general principle: instead of your code controlling the flow and deciding when each thing gets created, it is a framework that controls the flow and calls your code when the moment comes. When you write a CommandLineRunner, you do not invoke it: Spring does. When you write a @GetMapping, you do not call the method: the DispatcherServlet calls it. That is inversion of control.
Dependency injection (DI) is one concrete way of applying IoC to object construction: a class declares which collaborators it needs, but does not create them; someone external —the container— hands them over already built.
flowchart LR
subgraph WITHOUT["Without injection"]
A1["StationService"] -->|new| B1["InMemoryStationRepository"]
end
subgraph WITH["With injection"]
C["ApplicationContext"] -->|builds| B2["InMemoryStationRepository"]
C -->|builds and injects| A2["StationService"]
A2 -.->|depends on the interface| I["StationRepository"]
end
Spring's container, the ApplicationContext, is a dependency injector: it keeps a catalogue of beans and knows how to satisfy what each one asks for.
- Before and after: coupling versus injection
Let us look at it through the piece we are going to build in this lesson: working out the fare for a rental in Ribalta.
Before: the dependency is created inside
package com.ciclourbana.rentals;
import java.math.BigDecimal;
import java.time.Duration;
public class RentalService {
// The dependency is instantiated right here
private final StandardFare calculator = new StandardFare();
public BigDecimal finish(String plate, Duration duration) {
return calculator.calculate(duration);
}
}It works. And yet it has four serious problems:
- Coupling to a concrete implementation.
RentalServicedoes not depend on "some way of calculating fares": it depends onStandardFare. Adding the student fare forces you to modify this class. - Impossible to test in isolation. There is no way to swap the calculator for a controlled one in a test. Any test of
RentalServiceinevitably testsStandardFareas well. - Buried configuration. If
StandardFarehad to read the price per minute from the configuration,RentalServicewould have to know how to build it with those parameters. The responsibility spreads. - Uncontrolled lifecycle. Each
RentalServicecreates its own calculator. With ten services there would be ten identical calculators taking up memory.
After: the dependency is received
package com.ciclourbana.rentals;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
@Service
public class RentalService {
// Depends on the abstraction, not on an implementation
private final FareCalculator calculator;
// Spring hands over the collaborator already built
public RentalService(FareCalculator calculator) {
this.calculator = calculator;
}
public BigDecimal finish(String plate, Duration duration) {
return calculator.calculate(duration);
}
}The four problems disappear:
| Problem | How it is solved |
|---|---|
| Coupling | RentalService only knows the FareCalculator interface. |
| Testing | In a test all it takes is new RentalService(duration -> new BigDecimal("2.50")). No Spring, no database, nothing. |
| Configuration | The one who builds StandardFare is the container, which knows how to inject its properties. |
| Lifecycle | There is one shared instance, managed by Spring. |
The second point deserves emphasis: testability is not a side benefit of dependency injection, it is practically its reason for being. When we write tests with Mockito in module 6, swapping collaborators will be trivial precisely because of this design.
- The three types of injection
Spring supports three mechanisms. We look at them with the same class so we can compare them.
Constructor injection (recommended)
@Service
public class RentalService {
private final FareCalculator calculator;
private final StationService stationService;
// Since Spring 4.3, @Autowired is unnecessary if there is ONE single constructor
public RentalService(FareCalculator calculator, StationService stationService) {
this.calculator = calculator;
this.stationService = stationService;
}
}Setter injection
@Service
public class RentalService {
private FareCalculator calculator; // cannot be final
@Autowired
public void setCalculator(FareCalculator calculator) {
this.calculator = calculator;
}
}Field injection
@Service
public class RentalService {
@Autowired
private FareCalculator calculator; // Spring assigns it by reflection
}Side by side:
| Criterion | Constructor | Setter | Field |
|---|---|---|---|
Allows final (immutability) |
Yes | No | No |
| Object always in a valid state | Yes | No (there is an instant without the dependency) | No |
| Detects missing dependencies | At startup | At startup | At startup |
| Detects circular dependencies | At startup, with a clear error | Hides them | Hides them |
Instantiable with new in a test |
Yes | Yes, with extra setters | No (needs reflection or Spring) |
Requires @Autowired |
No (with one constructor) | Yes | Yes |
| Makes excess dependencies visible | Yes: the constructor grows and becomes annoying | Barely | No: fifteen fields feel the same |
| Supports optional dependencies | With @Nullable/ObjectProvider |
Yes, naturally | With required = false |
| Spring's official recommendation | Yes | Optional cases | Discouraged |
- Why constructor injection is the only advisable one
Five arguments, in order of importance:
1. Real immutability. Only the constructor lets you declare the fields final. A final field cannot be reassigned by accident or by a concurrent call, and the compiler guarantees it is assigned. With setters or fields, any code can change the dependency on the fly.
2. An object never exists half-built. With constructor injection, if the object exists, its dependencies are in place. With a setter there is a window between new RentalService() and setCalculator(...) in which the object is a NullPointerException waiting to happen.
3. Early, readable failure. If a bean is missing, Spring fails at startup with an explicit message, not in production at three in the morning:
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.ciclourbana.rentals.RentalService
required a bean of type 'com.ciclourbana.rentals.FareCalculator'
that could not be found.
Action:
Consider defining a bean of type 'com.ciclourbana.rentals.FareCalculator'
in your configuration.4. Testability without the framework. This is the most striking practical difference:
// With constructor injection: one line, no Spring
var service = new RentalService(duration -> new BigDecimal("1.80"), stationService);
// With field injection: there is no clean way.
// You need ReflectionTestUtils or to bring up the whole context
ReflectionTestUtils.setField(service, "calculator", fakeCalculator);5. The design pressure is healthy. A constructor with eight parameters is embarrassing to write, and that embarrassment is information: the class does too much and should be split. With field injection, eight @Autowired fields do not look bad and the class grows without limit. It is the worst effect of field injection, and it is cultural, not technical.
Since Spring 4.3, if the class has a single constructor, @Autowired is optional. In Spring Boot 3 the universal practice is to leave it out. With two or more constructors you do have to mark which one to use:
@Service
public class RentalService {
private final FareCalculator calculator;
@Autowired // required: there is more than one constructor
public RentalService(FareCalculator calculator) {
this.calculator = calculator;
}
// Auxiliary constructor for manual testing
public RentalService() {
this(duration -> BigDecimal.ZERO);
}
}
- How Spring resolves dependencies
The algorithm, in order:
flowchart TD
A["Constructor parameter:<br/>FareCalculator calculator"] --> B["Look for beans assignable<br/>to that type"]
B --> C{"How many are there?"}
C -- "0" --> D["Is it optional?"]
D -- No --> E["NoSuchBeanDefinitionException<br/>Startup failure"]
D -- "Yes (Optional/@Nullable)" --> F["Injects null or Optional.empty()"]
C -- "1" --> G["That bean is injected"]
C -- "2 or more" --> H{"Is there a @Qualifier<br/>at the injection point?"}
H -- Yes --> I["The bean with that name/qualifier is injected"]
H -- No --> J{"Is there a @Primary bean?"}
J -- Yes --> K["The @Primary one is injected"]
J -- No --> L{"Does the parameter name<br/>match a bean name?"}
L -- Yes --> M["Injected by name match"]
L -- No --> N["NoUniqueBeanDefinitionException<br/>Startup failure"]
The two fundamental criteria are, in this order: by type first, by name as a tie-breaker. Many people believe Spring injects by variable name; it only does so as a last resort, and relying on it is fragile (renaming the parameter is enough to break it).
- Several implementations of the same type:
@Primary and @Qualifier
@Primary and @QualifierThe time has come to build Ribalta's fare system. We start with the contract, in the new com.ciclourbana.rentals package:
package com.ciclourbana.rentals;
import java.math.BigDecimal;
import java.time.Duration;
/**
* Contract for calculating the amount of a rental on the Ribalta network.
* The concrete fares (standard, student, and whatever comes next)
* are different implementations of this interface.
*/
public interface FareCalculator {
/** Amount in euros for a rental of the given duration. */
BigDecimal calculate(Duration duration);
/** Readable identifier of the fare, for logs and invoices. */
default String name() {
return getClass().getSimpleName();
}
}And two implementations. The standard one:
package com.ciclourbana.rentals;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
/**
* Ribalta's general fare: €0.50 unlock + €0.12/minute.
* The amounts will move into configuration in lesson 02-05.
*/
@Component
@Primary // it is the network's default fare
public class StandardFare implements FareCalculator {
private static final BigDecimal UNLOCK = new BigDecimal("0.50");
private static final BigDecimal PER_MINUTE = new BigDecimal("0.12");
@Override
public BigDecimal calculate(Duration duration) {
BigDecimal minutes = BigDecimal.valueOf(Math.max(1, duration.toMinutes()));
return UNLOCK
.add(PER_MINUTE.multiply(minutes))
.setScale(2, RoundingMode.HALF_UP);
}
@Override
public String name() {
return "standard";
}
}And the student one:
package com.ciclourbana.rentals;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
/**
* Ribalta's university fare: no unlock charge and €0.08/minute.
* The first 15 minutes are free (agreement with the University).
*/
@Component("studentFare")
public class StudentFare implements FareCalculator {
private static final BigDecimal PER_MINUTE = new BigDecimal("0.08");
private static final long FREE_MINUTES = 15;
@Override
public BigDecimal calculate(Duration duration) {
long billable = Math.max(0, duration.toMinutes() - FREE_MINUTES);
return PER_MINUTE
.multiply(BigDecimal.valueOf(billable))
.setScale(2, RoundingMode.HALF_UP);
}
@Override
public String name() {
return "student";
}
}Now there are two beans of type FareCalculator. With no further guidance, startup would fail like this:
Parameter 0 of constructor in com.ciclourbana.rentals.RentalService
required a single bean, but 2 were found:
- standardFare: defined in file [.../StandardFare.class]
- studentFare: defined in file [.../StudentFare.class]Three ways to disambiguate:
@Primary: appointing a default winner
Since we annotated StandardFare with @Primary, any injection point asking for a FareCalculator with no further precision will receive that one. It is the right option when there is one clearly dominant case.
@Service
public class RentalService {
private final FareCalculator calculator; // receives StandardFare
public RentalService(FareCalculator calculator) {
this.calculator = calculator;
}
}@Qualifier: asking for a specific one
@Service
public class UniversityRentalService {
private final FareCalculator calculator;
public UniversityRentalService(
@Qualifier("studentFare") FareCalculator calculator) {
this.calculator = calculator;
}
}@Qualifier always beats @Primary. The value is the bean name or a qualifier declared with @Qualifier on the class itself.
A @Qualifier with string literals is fragile: a typo is only caught at startup. The robust alternative is a qualifier with an annotation of your own, which the compiler checks:
package com.ciclourbana.rentals;
import org.springframework.beans.factory.annotation.Qualifier;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.TYPE, ElementType.PARAMETER, ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface UniversityFare {
}public UniversityRentalService(@UniversityFare FareCalculator calculator) {
this.calculator = calculator;
}Now a typo does not compile. In large projects it is clearly preferable.
Comparison
| Mechanism | Where it is declared | When to use it |
|---|---|---|
@Primary |
On the bean | There is an obvious default implementation and the rest are exceptions. |
@Qualifier("name") |
At the injection point | One-off, when a specific implementation is needed. |
Your own qualifier (@UniversityFare) |
On the bean and at the injection point | Large projects: typo-safe, self-documenting. |
- Injecting every implementation:
List and Map
List and MapIn CicloUrbana the fare is not chosen by the developer: it is chosen by the user type at runtime. Picking between two injected beans with an if does not scale. The idiomatic solution is to ask Spring for every implementation at once.
Injecting a Map<String, T>
When the parameter type is Map<String, FareCalculator>, Spring injects a map with the bean name as the key and the bean as the value:
package com.ciclourbana.rentals;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.Map;
/**
* Selects the applicable fare according to the user type.
* Spring builds the map: key = bean name.
*/
@Service
public class FareSelector {
private static final Logger log = LoggerFactory.getLogger(FareSelector.class);
private final Map<String, FareCalculator> faresByName;
public FareSelector(Map<String, FareCalculator> faresByName) {
this.faresByName = faresByName;
log.info("Fares registered on the network: {}", faresByName.keySet());
}
public BigDecimal calculate(String fareName, Duration duration) {
FareCalculator calculator = faresByName.get(fareName);
if (calculator == null) {
throw new IllegalArgumentException(
"Unknown fare: " + fareName
+ ". Available: " + faresByName.keySet());
}
return calculator.calculate(duration);
}
}At startup you will see:
The great virtue of this pattern: adding a new fare requires no change to FareSelector. Just create a class annotated with @Component and it appears in the map. It is the open/closed principle made real in twelve lines of code.
One important detail: the keys are the bean names (standardFare), not the values of name() (standard). If you prefer to index by the business identifier, build the map yourself from a List:
@Service
public class FareSelector {
private final Map<String, FareCalculator> faresByName;
public FareSelector(List<FareCalculator> fares) {
// We index by the business identifier, not by the bean name
this.faresByName = fares.stream()
.collect(Collectors.toUnmodifiableMap(
FareCalculator::name, Function.identity()));
}
}With this variant the keys are standard and student, which is what will arrive over the API in module 3.
Injecting a List<T> and @Order
When order matters —a chain of validations, for example— Spring honours @Order when building the list: lower value, earlier.
package com.ciclourbana.rentals;
public interface RentalValidation {
void validate(String plate, Long stationId);
}@Component
@Order(1)
public class ValidateBikeAvailable implements RentalValidation { /* ... */ }
@Component
@Order(2)
public class ValidateUserHasNoDebt implements RentalValidation { /* ... */ }
@Component
@Order(3)
public class ValidateConcurrentRentalLimit implements RentalValidation { /* ... */ }@Service
public class RentalService {
private final List<RentalValidation> validations; // sorted by @Order
public RentalService(List<RentalValidation> validations) {
this.validations = validations;
}
public void start(String plate, Long stationId) {
validations.forEach(v -> v.validate(plate, stationId));
// ... rest of the rental
}
}Alternatives to @Order: implementing Ordered (which lets you compute the order) or using Jakarta's @Priority. @Order is the usual choice.
Beware of one nuance: @Order affects the order within an injected collection, not the order in which beans are created. For creation order, the tool is @DependsOn.
- Optional dependencies:
Optional and ObjectProvider
Optional and ObjectProviderSometimes a collaborator may not exist: a disabled module, an optional integration. There are three ways to express it.
package com.ciclourbana.rentals;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class RentalService {
private final FareCalculator calculator;
// Option A: Optional<T>. Never null; the signature makes it clear.
private final Optional<NotificationService> notifications;
// Option B: @Nullable. Simple, but the field can be null.
private final @Nullable LoyaltyService loyalty;
// Option C: ObjectProvider<T>. The most flexible one.
private final ObjectProvider<AuditService> audit;
public RentalService(FareCalculator calculator,
Optional<NotificationService> notifications,
@Nullable LoyaltyService loyalty,
ObjectProvider<AuditService> audit) {
this.calculator = calculator;
this.notifications = notifications;
this.loyalty = loyalty;
this.audit = audit;
}
public void finish(String plate) {
// A: used if it exists
notifications.ifPresent(n -> n.notifyRentalEnd(plate));
// B: manual check
if (loyalty != null) {
loyalty.addPoints(plate);
}
// C: lazy resolution, at the moment of use
audit.ifAvailable(a -> a.record("rental-finished", plate));
}
}ObjectProvider deserves a section of its own because it is the most powerful one:
| Method | What it does |
|---|---|
getIfAvailable() |
Returns the bean, or null if it does not exist. |
getIfUnique() |
Returns the bean only if there is exactly one; null if there are several. |
ifAvailable(Consumer) |
Runs the action only if the bean exists. |
getObject() |
Returns the bean; throws if it does not exist. Resolved on every call. |
stream() |
Every bean of the type, as a stream. |
orderedStream() |
The same, honouring @Order. |
Two key properties set it apart from the other options: resolution is lazy (the bean is looked up when you call the method, not when the object is constructed) and getObject() asks for a new instance every time if the bean has prototype scope. That second property is the answer to the classic problem of injecting a prototype into a singleton, which we will see in lesson 02-03.
- Circular dependencies
This happens when A needs B and B needs A:
@Service
public class RentalService {
public RentalService(StationService stationService) { /* ... */ }
}
@Service
public class StationService {
public StationService(RentalService rentalService) { /* ... */ }
}With constructor injection this is logically impossible: to build A you need B already built, and to build B you need A. Spring detects it and fails the startup, which is exactly what it should do:
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| rentalService defined in file [.../RentalService.class]
↑ ↓
| stationService defined in file [.../StationService.class]
└─────┘
Action:
Relying upon circular references is discouraged and they are prohibited by default.
Update your application to remove the dependency cycle between beans.In Spring Boot 3, cycles are forbidden by default. The property spring.main.allow-circular-references=true exists, and so does @Lazy on one of the two injection points. Do not use either as a solution. Both make the message go away without fixing anything, and the cycle is still there, ready to produce a NullPointerException or an unpredictable initialisation order.
A cycle is always a design symptom. The three real cures:
Cure 1: extract the shared responsibility into a third component. Usually the best one.
flowchart LR
subgraph BAD["Cycle"]
A["RentalService"] --> B["StationService"]
B --> A
end
subgraph GOOD["No cycle"]
A2["RentalService"] --> C["DockAvailability"]
B2["StationService"] --> C
end
If RentalService only needs the count of free docks from StationService, and StationService only needs the number of bikes in use from RentalService, extract that common knowledge into a DockAvailability that both depend on. The cycle disappears.
Cure 2: reverse the direction with events. If StationService only needs to react to something RentalService does, publish an event instead of calling the service:
// In RentalService: publish, do not call
publisher.publishEvent(new RentalStarted(plate, stationId));// In StationService: listen, do not get called
@EventListener
public void onDockReleased(RentalStarted event) {
// update the station's count
}We already used @EventListener in lesson 01-05 with the lifecycle events; here it is the same mechanism with events of our own. The coupling becomes one-directional.
Cure 3: review who should be calling whom. Often the cycle appears because a lower layer calls a higher one. If StationService calls RentalService, you have to ask whether that logic does not really belong to an orchestrator sitting above both.
Common Mistakes and Tips
Using @Autowired on private fields. It is still the first thing that shows up in old tutorials. It is the worst option: it rules out final, it stops you building the class in a test without reflection and it hides the runaway growth of dependencies. If your IDE is IntelliJ, you will see a yellow warning: listen to it.
Putting @Autowired on the only constructor. It is not an error, but it is noise: since Spring 4.3 it is unnecessary. Remove it.
Instantiating a bean with new. new StationService(...) creates an object that is not a bean: it receives no injections, it has no managed lifecycle, and @Transactional or @Cacheable do not work on it. If you need a Spring object, ask for it through the constructor.
Relying on name matching. Naming the parameter studentFare so that Spring picks that bean does work, but it is fragile: renaming the parameter —something an IDE does without warning— breaks the startup or, worse, silently injects a different bean. Use an explicit @Qualifier.
Injecting the ApplicationContext to call getBean(...). That is service locator, the antipattern dependency injection came to replace: it hides the class's real dependencies and makes testing impossible. It is justified in tooling and diagnostics (like the BeanVerifier from the previous lesson), never in business logic.
Solving a cycle with @Lazy. We repeat it because it matters: it is not a solution, it is an anaesthetic. Redesign.
Tip: if the constructor goes past four or five parameters, stop. It almost always means the class has more than one responsibility. Extract, do not add.
Tip: always depend on interfaces at the boundaries between layers. StationService depends on StationRepository, not on InMemoryStationRepository. That is what will let module 4 change the implementation without touching the service. Within a single layer, on the other hand, creating an interface with only one implementation is usually pointless ceremony.
Tip: do not annotate domain classes. Station and the future Rental or Bike are data, they are created with new and they take no part in injection.
Exercises
Exercise 1: a third fare without modifying the selector
Add a SeniorFare to the Ribalta network: no unlock charge, €0.05/minute, with the first 30 minutes free. Check that it shows up automatically in the FareSelector built from a List<FareCalculator>, without modifying a single line of FareSelector. Log the available fares at startup along with the amount of a 45-minute rental under each one.
Exercise 2: an ordered chain of validations
Implement three rental validations (RentalValidation) with @Order: the plate must follow the RB-NNNN format; the origin station must exist on the network; the station must have at least one bike (simulate it with the capacity). Inject them as a List<RentalValidation> into RentalService and verify that they run in order by attempting an invalid rental.
Exercise 3: breaking a cycle with events
Deliberately create a circular dependency between RentalService and StationService (inject each into the other), start the application and note down the error message. Then break the cycle by publishing a RentalStarted event from RentalService and listening for it in StationService.
Solutions
Solution 1
package com.ciclourbana.rentals;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
/**
* Fare for over-65s registered as residents of Ribalta:
* no unlock charge, €0.05/minute and 30 free minutes.
*/
@Component
public class SeniorFare implements FareCalculator {
private static final BigDecimal PER_MINUTE = new BigDecimal("0.05");
private static final long FREE_MINUTES = 30;
@Override
public BigDecimal calculate(Duration duration) {
long billable = Math.max(0, duration.toMinutes() - FREE_MINUTES);
return PER_MINUTE
.multiply(BigDecimal.valueOf(billable))
.setScale(2, RoundingMode.HALF_UP);
}
@Override
public String name() {
return "senior";
}
}The selector, untouched (this is how it should end up):
package com.ciclourbana.rentals;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class FareSelector {
private static final Logger log = LoggerFactory.getLogger(FareSelector.class);
private final Map<String, FareCalculator> byBusinessName;
public FareSelector(List<FareCalculator> fares) {
this.byBusinessName = fares.stream()
.collect(Collectors.toUnmodifiableMap(
FareCalculator::name, Function.identity()));
log.info("Fares available in Ribalta: {}", byBusinessName.keySet());
}
public BigDecimal calculate(String fare, Duration duration) {
FareCalculator calculator = byBusinessName.get(fare);
if (calculator == null) {
throw new IllegalArgumentException("Unknown fare: " + fare
+ ". Available: " + byBusinessName.keySet());
}
return calculator.calculate(duration);
}
public Map<String, FareCalculator> available() {
return byBusinessName;
}
}And the startup checker:
package com.ciclourbana.rentals;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@Order(10)
public class FareDemo implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(FareDemo.class);
private final FareSelector fareSelector;
public FareDemo(FareSelector fareSelector) {
this.fareSelector = fareSelector;
}
@Override
public void run(String... args) {
Duration duration = Duration.ofMinutes(45);
fareSelector.available().keySet().forEach(fare ->
log.info("45-minute rental on the '{}' fare: €{}",
fare, fareSelector.calculate(fare, duration)));
}
}Output:
c.c.rentals.FareSelector : Fares available in Ribalta: [standard, student, senior]
c.c.r.FareDemo : 45-minute rental on the 'standard' fare: €5.90
c.c.r.FareDemo : 45-minute rental on the 'student' fare: €2.40
c.c.r.FareDemo : 45-minute rental on the 'senior' fare: €0.75Comment: the point of the exercise is to confirm that FareSelector did not change. Extending the system consisted solely of adding a class. Frequent mistake: forgetting @Component on the new fare; it then does not appear in the list and the failure is silent, with no error message. If something does not turn up in an injected collection, the first thing to check is whether the class really is a bean.
Solution 2
package com.ciclourbana.rentals;
public interface RentalValidation {
void validate(String plate, Long stationId);
}package com.ciclourbana.rentals;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.regex.Pattern;
@Component
@Order(1)
public class ValidatePlateFormat implements RentalValidation {
private static final Pattern FORMAT = Pattern.compile("RB-\\d{4}");
@Override
public void validate(String plate, Long stationId) {
if (plate == null || !FORMAT.matcher(plate).matches()) {
throw new IllegalArgumentException(
"Invalid plate: '" + plate + "'. Expected format: RB-0142");
}
}
}package com.ciclourbana.rentals;
import com.ciclourbana.stations.StationService;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
@Order(2)
public class ValidateStationExists implements RentalValidation {
private final StationService stationService;
public ValidateStationExists(StationService stationService) {
this.stationService = stationService;
}
@Override
public void validate(String plate, Long stationId) {
stationService.findById(stationId).orElseThrow(() ->
new IllegalArgumentException(
"Station " + stationId + " does not exist on the Ribalta network"));
}
}package com.ciclourbana.rentals;
import com.ciclourbana.stations.StationService;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
@Order(3)
public class ValidateStationOperational implements RentalValidation {
private final StationService stationService;
public ValidateStationOperational(StationService stationService) {
this.stationService = stationService;
}
@Override
public void validate(String plate, Long stationId) {
// Simulation: until module 4 there is no real bike inventory
stationService.findById(stationId)
.filter(station -> station.capacity() >= 8)
.orElseThrow(() -> new IllegalStateException(
"Station " + stationId + " is out of service"));
}
}package com.ciclourbana.rentals;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class RentalService {
private static final Logger log = LoggerFactory.getLogger(RentalService.class);
private final FareCalculator defaultCalculator; // the @Primary one
private final List<RentalValidation> validations; // sorted by @Order
public RentalService(FareCalculator defaultCalculator,
List<RentalValidation> validations) {
this.defaultCalculator = defaultCalculator;
this.validations = validations;
log.info("RentalService with fare '{}' and {} validations: {}",
defaultCalculator.name(),
validations.size(),
validations.stream().map(v -> v.getClass().getSimpleName()).toList());
}
public void start(String plate, Long stationId) {
validations.forEach(v -> v.validate(plate, stationId));
log.info("Rental started: bike {} at station {}", plate, stationId);
}
}Output at startup and when trying an invalid plate:
c.c.rentals.RentalService : RentalService with fare 'standard' and 3 validations:
[ValidatePlateFormat, ValidateStationExists, ValidateStationOperational]
...
java.lang.IllegalArgumentException: Invalid plate: 'RB-14'. Expected format: RB-0142Comment: when injecting a List, Spring sorts by @Order from lowest to highest. If you remove the @Order annotations, the order becomes the scan's discovery order, which is stable in practice but not guaranteed: never depend on it. Tip: when the order genuinely matters, leave gaps between the values (10, 20, 30) so you can slot validations in between without renumbering.
Solution 3
First, the deliberate cycle:
@Service
public class RentalService {
public RentalService(StationService stationService) { /* ... */ }
}
@Service
public class StationService {
// Causes the cycle
public StationService(StationRepository repo, RentalService rentalService) { /* ... */ }
}Startup fails with the ┌─────┐ ... └─────┘ box shown in section 9. That diagram in the log is one of Spring Boot's best diagnostic tools: it says exactly which beans form the cycle and where they are defined.
Now the solution with events. The event, a record in the rentals package:
package com.ciclourbana.rentals;
import java.time.Instant;
/** Domain event: a rental has started on the network. */
public record RentalStarted(String plate, Long stationId, Instant occurredAt) {
public RentalStarted(String plate, Long stationId) {
this(plate, stationId, Instant.now());
}
}The publisher:
package com.ciclourbana.rentals;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class RentalService {
private final ApplicationEventPublisher publisher;
// It no longer injects StationService: the cycle disappears
public RentalService(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void start(String plate, Long stationId) {
// ... validations and rental logic ...
publisher.publishEvent(new RentalStarted(plate, stationId));
}
}The receiver:
package com.ciclourbana.stations;
import com.ciclourbana.rentals.RentalStarted;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
@Service
public class StationService {
private static final Logger log = LoggerFactory.getLogger(StationService.class);
private final StationRepository stationRepository;
// No RentalService: one-directional dependency
public StationService(StationRepository stationRepository) {
this.stationRepository = stationRepository;
}
@EventListener
public void onRentalStarted(RentalStarted event) {
log.info("Dock released at station {} by bike {}",
event.stationId(), event.plate());
// In module 4 this will update the persisted count
}
// ... the rest of the methods from the previous lesson
}Comment: ApplicationEventPublisher is a bean Spring always provides; injecting it creates a dependency on nobody. The result is that RentalService does not know who reacts to its events: there could be zero, one or five listeners. The coupling goes from two-directional to non-existent.
Two cautions worth knowing about right away: @EventListener methods are synchronous by default (they run on the same thread and inside the same transaction as the publisher); if the listener throws an exception, it propagates back to the publisher. Making them asynchronous with @Async is covered in lesson 07-03.
Conclusion
Dependency injection has stopped being an annotation you copy. You know that IoC is the principle —the framework calls your code— and that DI is its application to object construction. You have seen, in a concrete example, the four problems a new inside a class causes and how they vanish once the collaborator arrives through the constructor. You know the three injection styles and the five arguments for why only the constructor is defensible, with testability and design pressure at the top. You know how Spring resolves an injection point: by type first, with @Qualifier outranking @Primary, and by name only as a last resort. You know how to inject every implementation of an interface into a Map or a List sorted with @Order, a pattern that makes the system extensible without touching the code that consumes it. You know how to express an optional dependency with Optional, @Nullable or ObjectProvider. And you know that a circular dependency is not patched with @Lazy: it is redesigned by extracting what is common or reversing the direction with events.
CicloUrbana already has its first extensible system: the FareCalculator interface with StandardFare (marked @Primary) and StudentFare, the FareSelector that indexes them by business name, and a RentalService that orchestrates ordered validations. The amounts are still hard-coded in constants; in lesson 02-05 we will move them out into the configuration.
Before that, there is a question we have brushed against several times without answering. We have said that Spring creates one instance of each bean and that this is why InMemoryStationRepository uses a ConcurrentHashMap. Why only one? Can there be more? How long does each bean live, and what exactly happens between it being instantiated and it becoming available? The next lesson, Bean Scopes and Lifecycle, answers all of that: the available scopes, the danger of mutable state in a singleton, the complete lifecycle with @PostConstruct and @PreDestroy, lazy initialisation and the extension point —BeanPostProcessor— that a good deal of Spring's apparent magic rests on.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
