1. Controller: a thin web layer on top of service
Once it is clear how an HTTP request reaches a controller method, we can calmly assemble a real catalog controller. But it is very easy to mix up the roles and start writing business logic directly in the controller, because, “well, the request came here, so why not?”. At this stage, we deliberately draw the boundary: the controller is an adapter, not the brain.
If we reduce it to a straightforward engineering picture, we have “layers”, and each one solves its own problem. The controller solves the problem of “how to read the request and what to return”. The service solves the problem of “what counts as the correct answer according to the application's rules”. The repository solves the problem of “where to get the data from (in our case, from memory)”.
flowchart TD
A["HTTP client: browser/curl/Postman"] --> B["Embedded Tomcat"]
B --> C[DispatcherServlet]
C --> D[CourseCatalogController]
D --> E[CourseCatalogService]
E --> F[CourseCatalogRepository]
F --> G["(In-memory data)"]
Important point: we do not create the controller with new. We do not call its methods manually. It lives in the Spring context, and Spring MVC calls it. So if you ever feel the urge to write new CourseCatalogController(...), stop, take a breath, and remember: this is no longer a console application; it is an “adult” system where object lifecycles are managed by the container.
And one more boundary worth keeping in mind: the controller is where HTTP details appear (paths, parameters, statuses). The service is where domain details appear (courses, featured, published). You can mix these two worlds, but you will end up with “soup” that is hard to maintain: new parameters, checks, exceptions, and “let's add one more filter here” will keep sticking to it. We are learning to do the opposite.
2. @RestController and the response body
By now, the main thing is clear: the catalog controller should not look for HTML pages; it should return service data to the outside world. That is why the base annotation here is @RestController: it tells Spring MVC that the method return value should be written directly into the HTTP response body.
If we used @Controller, then a string return value would be interpreted as a view name by default. For catalog-service, that is not what we need: we are building a read-only JSON service, and our web layer should return data, not render pages. The more targeted @ResponseBody variant also exists, but for such a controller it is simpler and more honest to put @RestController directly on the class. With this foundation, we can immediately assemble CourseCatalogController: the web layer reads HTTP input and returns service data to the outside world.
3. The skeleton of CourseCatalogController
Now we will assemble the main class of today's lecture: the catalog controller. Here it is important not only “which annotations to write”, but also how to keep the code readable. The most common small beginner problem is that the same URL prefix is repeated in every method, and dependencies get pulled in in a messy way. We will do it neatly: move the common prefix to the class level and inject the dependency through the constructor.
Let's agree on the base URL. We want all catalog endpoints to live under one “roof” — /api/catalog. Then the routes are readable at a glance: “this is the API, this is the catalog, this is the courses”. And if new endpoints appear tomorrow, they will not scatter across the project like socks after laundry.
(at the beginning of the file there will be package com.example.catalogservice.catalog.web;)
import com.example.catalogservice.catalog.service.CourseCatalogService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // Controller that returns data (usually JSON), not HTML pages
@RequestMapping("/api/catalog") // Common prefix for all methods of this controller
public class CourseCatalogController {
private final CourseCatalogService service; // Dependency on business logic (service), not on repository
public CourseCatalogController(CourseCatalogService service) {
this.service = service; // Constructor injection: the dependency is required and visible
}
}
There are several important things here that are worth saying out loud (yes, you can whisper if you are in the office).
First, @RestController makes this class a Spring bean, and Spring MVC will see it and register its routes. Second, @RequestMapping("/api/catalog") is not “magic” — it is just a prefix: the paths of methods will be appended to it. Third, constructor injection is our default style: the dependency CourseCatalogService becomes required and visible. If the service fails to create, the application will crash on startup, not “some time later”.
And a very important architectural habit: the controller depends on the service, not on the repository. The repository is the “internal kitchen”. The controller is the “waiter”. A waiter should not go into the kitchen and fry the cutlets themselves. Otherwise, they stop being a waiter and become a very tired person.
4. Course list: GET /api/catalog/courses
The first endpoint is almost always the most pleasant one, because it gives you the feeling that “it really works”. But it is also where people most often develop a bad habit: they start filtering, sorting, and limiting directly in the controller, and in the end the controller turns into a mini-service. We do exactly one thing: accept the request and delegate.
In our case, the course list is GET /api/catalog/courses. We will return a list of CourseCard. At this stage, we do not need any DTOs, any “wrappers like { data: ... }”, and certainly no manual strings like "{\"id\":...}". We simply return Java objects, and MVC will convert them to JSON through HttpMessageConverter.
Inside CourseCatalogController, add the method:
import com.example.catalogservice.catalog.domain.CourseCard;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;
@GetMapping("/courses") // GET /api/catalog/courses (the prefix comes from @RequestMapping on the class)
public List<CourseCard> findAll() {
// The controller does not filter or sort: it just delegates to the service
return service.findAll();
}
If you now ask, “Where is the JSON here?” — the honest answer will be: “Inside Spring MVC and Jackson”. That is the whole point of Boot's starter and platform style: you write business code, and the infrastructure handles format conversion.
To make it completely clear, let's imagine that service.findAll() returns two courses. Then the JSON response will look roughly like this (the formatting may differ, but the idea is the same):
[
{ "id": "spring-boot", "slug": "spring-boot", "title": "Spring Boot" },
{ "id": "spring-core", "slug": "spring-core", "title": "Spring Core" }
]
And one more important nuance: this method should not print to the console, should not build strings, and should not catch exceptions “just in case”. If something breaks, Boot will return a standard error. Our job right now is to make the first clean web entry point to the service.
5. Course by slug and 404
The list is good, but a real catalog service usually knows how to return a specific entity by key. In our case, the key is slug (a human-readable identifier in the URL). And here an important practical detail appears: what should we do if the course is not found? Sure, we could “return null and hope for the best”, but it is better not to do that — hope has poor statistics in production.
In the minimal version, we want a correct HTTP status of 404 Not Found. We are not designing a full error contract and not writing global exception handling, but status 404 is basic API politeness: “this resource does not exist”.
Let's do it this way: the service will return Optional<CourseCard>, and the controller will convert it to ResponseEntity.
Method in the controller:
import com.example.catalogservice.catalog.domain.CourseCard;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/courses/{slug}") // GET /api/catalog/courses/{slug}
public ResponseEntity<CourseCard> findBySlug(@PathVariable String slug) {
// If the course is found -> 200 OK + JSON body. If not -> 404 Not Found with no body.
return service.findBySlug(slug)
.map(ResponseEntity::ok) // Successful response: status 200
.orElseGet(() -> ResponseEntity.notFound().build()); // Resource not found: status 404
}
Note two things. First: @PathVariable String slug binds the path segment {slug} to the method argument. Second: we explicitly say which response to return if the course is found (200 OK + body), and which to return if it is not found (404 with no body).
And for this to work, the service must be able to return an Optional. For example, in CourseCatalogService it might look like this (an idea, not the “only true religion”):
import com.example.catalogservice.catalog.domain.CourseCard;
import java.util.Optional;
public Optional<CourseCard> findBySlug(String slug) {
// The service does not “invent” HTTP statuses: it simply says whether we found the course or not
return repository.findBySlug(slug);
}
In the end, the controller stays thin, and the “exists/doesn't exist” decision lives where it is comfortable: the service and repository know the data, the controller knows the HTTP response.
6. Featured: GET /api/catalog/featured
Now let's add a route that nicely demonstrates the idea of “one service, several clear entry points”. The featured selection is not a new data type. It is the same CourseCard, just selected by the rule “featured = true”. And the important point is: the selection rule is catalog business logic, not an HTTP detail. Therefore, filtering should live in the service, and the controller should only call the right method.
The route will be: GET /api/catalog/featured. In essence, this is a “view” of the catalog: the courses we want to show as recommended.
Controller method:
import com.example.catalogservice.catalog.domain.CourseCard;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;
@GetMapping("/featured") // GET /api/catalog/featured
public List<CourseCard> featured() {
// Keep the logic of “what is featured” in the service; here we only have the HTTP entry point
return service.findFeatured();
}
And here is an example of how this might be implemented in the service (and this is exactly a demonstration of “logic in the service, not in the controller”):
import com.example.catalogservice.catalog.domain.CourseCard;
import java.util.List;
public List<CourseCard> findFeatured() {
// Filtering by the business rule: featured = true
return repository.findAll().stream()
.filter(CourseCard::featured) // Keep only recommended courses
.toList();
}
Yes, it is a tiny Stream. Yes, it reads fine. And yes, this is exactly the place where filtering is appropriate: the service is responsible for “what featured means”.
Important: we are not discussing yet how many elements to return, or where the limit comes from. At this stage, the idea itself is enough: a separate endpoint, a separate service method, a clear JSON response.
7. Serializing CourseCard to JSON
When you first return List<CourseCard> from a controller, it feels like there is a tiny gnome somewhere inside Spring writing JSON by hand. In reality, it is simpler and more boring (and boring is usually good): Spring MVC picks the appropriate HttpMessageConverter, and for JSON it usually uses Jackson. Jackson looks at your object like a regular Java object: fields, getter's, record components — and turns them into JSON.
To see it clearly, a minimal CourseCard is enough. For example, in the domain package, it can be a record (records are a great fit for “cards”: data is immutable, the structure is clear, there are no extra setters).
import java.time.LocalDate;
// Domain model for a “course card” that we serialize directly to JSON
public record CourseCard(
String id, // Internal course identifier
String slug, // Human-readable key for the URL (for example, spring-boot)
String title, // Course title
boolean featured, // “Recommended” flag
LocalDate launchDate // Launch date (usually becomes a string like "2026-04-01" in JSON)
) { }
If you return such an object from a controller, the JSON will have the fields id, slug, title, featured, and launchDate. Dates are usually serialized as strings, for example "2026-04-01". At this stage, we are not configuring the format, not writing custom serializers, and not adding Jackson annotations “just in case”. Our goal is basic predictability: return a Java object, get JSON.
It is useful to keep a simple matching table so there are no surprises:
| Java type | What it becomes in JSON |
|---|---|
| String | string |
int, , |
number |
| boolean | true/false |
| List<T> | array |
| record / POJO | object |
| null | null (if the field is present) |
And one more practical recommendation: at the early stage, it is better to return “plain” data objects. If you start returning something like Optional<CourseCard> directly from the controller, it may serialize unexpectedly (or simply look strange to the client). Therefore, Optional is an excellent type inside services, but outward (in HTTP) it is better to return either a concrete object or ResponseEntity.
8. Common mistakes when working with controllers
When you start writing controllers, mistakes usually fall into two categories: “I did not understand how Spring MVC works” and “I understood it, but decided to do it my own way because it is faster”. The second category usually hurts more, but the first one is more common, so let's start there.
Mistake No. 1: using @Controller and wondering why JSON does not arrive.
A beginner writes a method, returns List<CourseCard>, runs it, makes a request — and sees strange behavior (or a view-related error). The reason is almost always that @Controller without @ResponseBody is oriented toward views. For JSON responses in our service, the base annotation is @RestController, and it also makes the code's intention obvious.
Mistake No. 2: putting complex logic inside the controller “because everything is right here”.
Your hands naturally reach for stream().filter(...).sorted(...).limit(...) directly in the endpoint, especially when the filter is simple. The problem is not the stream, but the fact that the controller starts to know about business rules. In a couple of days, a second filter will be added there, then a third, then “if this parameter is present, do it like this”, and the controller turns into a mini-monster that is impossible to read. The right habit is for the controller to only receive the input parameters and call a service method.
Mistake No. 3: field injection (@Autowired on a field) is a quiet way to lose control over dependencies.
Field injection seems “shorter”, but it makes dependencies implicit, worsens testability, and easily leads to situations like “it starts, but no one knows why”. Constructor injection, on the other hand, forces dependencies to be honest. In a learning project, that is especially important: we are building not only code, but also good habits.
Mistake No. 4: exposing Optional as part of the HTTP contract.
Optional is an excellent tool inside Java code, but in a web contract it looks strange: the client does not need to know that you used Optional inside the service. The client needs either a resource (200) or its absence (404). Therefore, it is better to “unwrap” Optional in the controller and respond via ResponseEntity.
Mistake No. 5: trying to “make it nice” and immediately build a DTO layer, wrappers, and universal responses.
At the first web baseline, this looks like “I am a grown-up developer”, but it usually turns into “I just built myself a micro-framework on top of Spring”. Right now our goal is to raise a minimal, honest service: GET routes, clear URLs, simple domain objects in JSON. The beauty here is in clarity, not in the number of abstractions.
GO TO FULL VERSION