Spring Boot Course · Lesson 3
Your BookController currently does two unrelated jobs: it speaks HTTP and it holds the data. In this lesson you'll split the data work into a service, let Spring wire the two together, and — the real payoff — feel what dependency injection buys you by constructing your controller in plain Java, no Spring in sight.
GET /api/books returns exactly what it did before — but now the controller is a thin web layer that delegates to a BookService bean Spring hands it. You'll prove the wiring is yours to control by instantiating the controller yourself.
Lesson 2's controller works, so why touch it? Because it conflates two concerns. Handling the HTTP request — matching the URL, choosing JSON — is web work. Deciding what the books are is business work. Right now both live in the same class, and that list of books is hard-coded inside a method that's supposed to be about routing.
The standard cure is a layered architecture: a thin stack where each layer has one job and only talks to the layer below it.
Controller ← web layer: HTTP in, JSON out (Lessons 1–2)
│ delegates to
Service ← business layer: the app's logic ← you add this today
│ asks for data from
Repository ← data layer: talks to the database (Lesson 5) Today you add the middle layer. The repository is still a hard-coded list living inside the service for now; in Lesson 5 it becomes a real database-backed layer, and the service won't have to change much — that's the point of keeping the layers separate. This split is called separation of concerns.
BookService
Move the data work out of the controller and into a new class in the same feature package. The @Service annotation marks it as a business-logic component Spring should manage:
src/main/java/com/example/library/book/BookService.java
package com.example.library.book;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Service;
@Service
public class BookService {
public List<Book> findAll() {
return List.of(
new Book(UUID.fromString("11111111-1111-1111-1111-111111111111"), "Clean Code", "Robert C. Martin", 2008),
new Book(UUID.fromString("22222222-2222-2222-2222-222222222222"), "Effective Java", "Joshua Bloch", 2018),
new Book(UUID.fromString("33333333-3333-3333-3333-333333333333"), "The Pragmatic Programmer", "Hunt & Thomas", 1999)
);
}
public Book findFeatured() {
return new Book(UUID.fromString("11111111-1111-1111-1111-111111111111"),
"Clean Code", "Robert C. Martin", 2008);
}
}
Notice the method names changed flavour: findAll() / findFeatured() read like questions you ask a data source, not like HTTP handlers. That's deliberate — the service speaks the language of the domain, not the web.
@Service, really?
It's a stereotype annotation — a label that tells Spring's component scan "create one of these and manage it as a bean." @Service, @Repository, @Controller, and @RestController are all specialised forms of the base @Component. Functionally they register a bean the same way; the different names document the class's role in the architecture (and a couple gain extra behaviour later — e.g. @Repository translates database exceptions). Use the one that matches the layer.
The controller now asks for a BookService in its constructor and delegates to it. It no longer knows or cares where the books come from:
src/main/java/com/example/library/book/BookController.java
package com.example.library.book;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService books; // a dependency, not created here
public BookController(BookService books) { // Spring passes it in
this.books = books;
}
@GetMapping // GET /api/books
public List<Book> all() {
return books.findAll();
}
@GetMapping("/featured") // GET /api/books/featured
public Book featured() {
return books.findFeatured();
}
}
Run the app (./mvnw spring-boot:run) and hit http://localhost:8080/api/books — identical JSON to Lesson 2. From the outside, nothing changed. Inside, the responsibilities are now clean: the controller routes, the service supplies.
@Service BookService and @RestController BookController, and created one of each. To build the controller it saw the constructor needs a BookService, looked one up in the context, and passed it in. You never wrote new BookService(). That hand-off — the framework creating your objects and supplying their collaborators — is dependency injection, and the broader principle of the framework (not your code) being in charge of object creation is inversion of control.
@Autowired? Correct.
You may have seen @Autowired in older examples. Since Spring 4.3, if a bean has a single constructor, Spring uses it for injection automatically — the annotation is redundant. One constructor, no annotation: that's the modern style.
Definitions of DI are easy to nod along to and just as easy to forget. So let's make it concrete. Because the controller receives its service instead of constructing one, you can build a fully-working controller yourself, in plain Java, with no Spring and no running server:
// Plain Java — no @SpringBootApplication, no web server, runs instantly.
BookService service = new BookService();
BookController controller = new BookController(service); // you do the injecting
List<Book> result = controller.all();
System.out.println(result.size()); // 3 Stop and appreciate what just happened. The controller had a dependency, and you satisfied it by passing an object to the constructor — exactly what Spring does at runtime, except here you're in control. The class never reaches out to grab its collaborators; they're handed in. That's the whole idea, and it's why people say good DI code is "just constructors."
BookService talks to a real database. To test the controller's web behaviour you don't want a database — slow, flaky, hard to set up. Because the controller takes its service through the constructor, a test can hand it a fake BookService that returns canned data, with zero Spring and zero database:
BookService fake = new BookService() {
@Override public List<Book> findAll() {
return List.of(new Book(UUID.randomUUID(), "Test Book", "Tester", 2024));
}
};
BookController controller = new BookController(fake);
// now assert on controller.all() — fast, isolated, deterministic
That swap is only possible because the dependency comes in from outside. A controller that did new BookService() internally would drag the database into every test. This testability is the concrete reason layered + injected code is considered "hireable" — you'll do it for real in Lesson 8.
Spring can also inject straight into a field (@Autowired private BookService books;). You'll see it in older code, but constructor injection is the recommended style, and here's why it wins:
final. A constructor-injected field is set once and never reassigned — the dependency is immutable, and the compiler enforces it.NullPointerException on the first request.new, so it forces a Spring context (or reflection) into your tests.NoSuchBeanDefinitionException: BookService? Spring didn't register the service. Check the @Service annotation is present and the class sits under com.example.library (where component scanning reaches).@Autowired on the one it should use. With a single constructor, omit it.new BookService() inside the controller? Don't — that throws away the injection. Let the constructor receive it; Spring (or your test) supplies it.Retrieval beats re-reading. Answer from memory before clicking.
@Autowired on the controller's constructor?new BookController(service) with no Spring running. What did that demonstrate?Primary source: the Spring reference on Dependency Injection — read the Constructor-based Dependency Injection section. It's the official statement of exactly the pattern you just used, and Spring's own guidance recommends constructors for mandatory dependencies for the reasons listed above.
New vocabulary from this lesson lives in the Glossary — your quick-reference for every term we use.