Most people who bounce off Spring Boot do not bounce off Spring. They bounce off the Java underneath it — a List<Notifier> that arrives filled in from nowhere, a JpaRepository<User, Long> with two type parameters and no body, an @GetMapping that apparently rewrites the program. None of that is Spring. All of it is ordinary Java that Spring leans on hard.
This article is the Java prerequisite list for the rest of the series, and every item is taught through the exact place Spring uses it. Everything below was compiled and run on OpenJDK 21.0.6 (arm64), against the real jars Spring Boot 4.1.1 resolves, so the output and the compiler errors are the ones the toolchain actually printed.
![]()
Read it as a checklist. If a section is already obvious to you, skip it; if it is not, that section is where Spring will confuse you later.
What this series builds
The series builds a REST API backed by a relational database, one layer at a time: HTTP endpoints, a service layer, persistence with Spring Data JPA, validation, error handling and tests. It is written for someone who can already write and compile Java and has never used a framework.
| Choice | Why it matters here | |
|---|---|---|
| Framework | Spring Boot 4.1.1 | pulls in Spring Framework 7.0.9 |
| Language | Java 21 (LTS) | records, var, pattern matching, Stream.toList() all available |
| Build tool | Gradle, via the ./gradlew wrapper | no Gradle install needed; Maven is compared later in the series |
| Minimum JDK | 17 | Boot 4.x will not load on anything older |
That last row is not folklore. The class files inside spring-boot-4.1.1.jar are compiled to class file major version 61, which is Java 17:
javap -v -cp spring-boot-4.1.1.jar org.springframework.boot.SpringApplication | grep 'major version' major version: 61Java 21 is what this series uses because it is the current LTS and because records and Stream.toList() remove a lot of noise from the examples.
What Spring Framework and Spring Boot actually are, what auto-configuration and starters do, and where the embedded server comes from are the subject of the next article. This one stops at the Java.
Here is the map. Each row is a section below, and the right column is the Spring API that will not make sense without it.
| Java feature | Where you meet it in Spring |
|---|---|
| interface + polymorphism | a service field typed as an interface, with the implementation chosen elsewhere |
List, Set, Map | List<SomeInterface> holding every implementation; Map<String, T> keyed by name |
| generics | JpaRepository<User, Long>, ResponseEntity<T>, Optional<T> |
| lambda + functional interfaces | orElseThrow(() -> ...), configuration written as cfg -> cfg.something() |
Stream | turning a list of entities into a list of DTOs |
Optional | the return type of findById |
record | request and response DTOs bound to JSON |
| annotation + reflection | @Component, @GetMapping, @Entity — all of it |
Interfaces and polymorphism: the shape Spring expects
An interface is a list of method signatures with no behaviour. A class that implements it promises to supply that behaviour. The only thing that matters for Spring is what this buys the caller: it can be written against the interface and never mention a concrete class.
interface Notifier {
String channel();
void send(String to, String message);
}
class EmailNotifier implements Notifier {
public String channel() { return "email"; }
public void send(String to, String message) {
System.out.println("[email] to=" + to + " body=" + message);
}
}
class SmsNotifier implements Notifier {
public String channel() { return "sms"; }
public void send(String to, String message) {
System.out.println("[sms] to=" + to + " body=" + message);
}
}The caller declares the interface and takes it through the constructor. There is no new EmailNotifier() anywhere inside it:
class OrderService {
private final Notifier notifier;
OrderService(Notifier notifier) {
this.notifier = notifier;
}
void placeOrder(String customer, String sku) {
System.out.println("order placed: " + sku + " (notifier=" + notifier.channel() + ")");
notifier.send(customer, "Order " + sku + " confirmed");
}
}Polymorphism is the second half: the same call site produces different behaviour depending on what was passed in.
new OrderService(new EmailNotifier()).placeOrder("an@example.com", "SKU-1");
new OrderService(new SmsNotifier()).placeOrder("+84900000001", "SKU-2");order placed: SKU-1 (notifier=email)
[email] to=an@example.com body=Order SKU-1 confirmed
order placed: SKU-2 (notifier=sms)
[sms] to=+84900000001 body=Order SKU-2 confirmed
Look at where the two halves live. Inside OrderService the only type named is Notifier. Outside it, someone writes new EmailNotifier(). That gap — the caller declares a type, somebody else supplies an instance — is a hole in the program, and filling it automatically is what dependency injection does. This series covers that properly later; the point for now is that DI is not a new language feature, it is a mechanical answer to a gap this code shape already has.
Three related rules are worth having straight before then:
| Construct | Can hold state | Can have behaviour | A class can have |
|---|---|---|---|
interface | no (only constants) | default and static methods | many |
abstract class | yes | yes, including constructors | exactly one superclass |
record | yes, immutable only | yes, but no extra fields | it is implicitly final |
Prefer an interface when you expect more than one implementation or you want to substitute a fake in tests. Reach for an abstract class only when several implementations genuinely share state.
Collections: List, Set and Map
Three interfaces cover almost everything a Spring application does with groups of objects.
| Interface | Guarantees | Usual implementation | Typical use in a Spring app |
|---|---|---|---|
List<E> | ordered, duplicates allowed, indexed | ArrayList | rows returned from a repository |
Set<E> | no duplicates, order not guaranteed | HashSet, LinkedHashSet | roles, tags, unique ids |
Map<K,V> | key to value, one entry per key | HashMap, LinkedHashMap | lookup tables, request parameters |
List<String> roles = new ArrayList<>(List.of("ADMIN", "USER", "USER"));
Set<String> distinct = new HashSet<>(roles);
Map<String, Integer> limits = new HashMap<>();
limits.put("ADMIN", 1000);
limits.put("USER", 50);
System.out.println("list = " + roles + " size=" + roles.size());
System.out.println("set = " + distinct + " size=" + distinct.size());
System.out.println("map = " + limits);
System.out.println("get USER = " + limits.get("USER"));
System.out.println("get NONE = " + limits.get("NONE"));
System.out.println("getOrDef = " + limits.getOrDefault("NONE", 0));list = [ADMIN, USER, USER] size=3
set = [ADMIN, USER] size=2
map = {ADMIN=1000, USER=50}
get USER = 50
get NONE = null
getOrDef = 0Two behaviours in that output cause real bugs. Map.get returns null for a missing key rather than throwing, which is why getOrDefault exists. And a Set silently absorbed the duplicate USER — three elements went in, two came out.
The collection shape that will surprise you in Spring is a List of an interface type. When several classes implement Notifier, Spring can hand a caller all of them in one List<Notifier>, and a Map<String, Notifier> keyed by name. The Java side of that is nothing special — it is just a list of instances, all viewed through the interface:
List<Notifier> injected = List.of(new EmailNotifier(), new SmsNotifier());
Map<String, Notifier> byChannel = injected.stream()
.collect(Collectors.toMap(Notifier::channel, Function.identity()));
System.out.println("byChannel keys = " + byChannel.keySet());
byChannel.get("sms").send("+84900000001", "ping");byChannel keys = [sms, email]
[sms] +84900000001 pingNote the key order: HashMap does not preserve insertion order, and the sms entry came out first even though email was inserted first. If order matters, say so with LinkedHashMap.
One trap that bites everyone at least once. List.of(...), Map.of(...) and Collectors.toList()-style results are not all mutable. The factory methods return immutable collections:
List.of("a").add("b");List.of is immutable -> java.lang.UnsupportedOperationException: nullThe message really is null — the exception carries no detail. When a repository or a library hands you a list, copy it with new ArrayList<>(list) before you mutate it.
Generics: type parameters, bounds and erasure
A type parameter is a placeholder for a type that the caller fills in. List<String> is List<E> with E fixed to String, and the compiler then refuses anything else:
List<String> names = new ArrayList<>();
names.add(42);Unsafe.java:7: error: incompatible types: int cannot be converted to String
names.add(42);
^You will write your own type parameters rarely, but you read them constantly. This is the shape Spring Data uses, and there is no magic in it — an interface with two parameters, one for the entity and one for its id:
interface CrudRepository<T, ID> {
T save(T entity);
Optional<T> findById(ID id);
List<T> findAll();
}
class UserRepository implements CrudRepository<User, Long> {
private final Map<Long, User> rows = new LinkedHashMap<>();
public User save(User entity) {
rows.put(entity.id(), entity);
return entity;
}
public Optional<User> findById(Long id) {
return Optional.ofNullable(rows.get(id));
}
public List<User> findAll() {
return List.copyOf(rows.values());
}
}findById(1) = Optional[User[id=1, name=An]]
findById(99) = Optional.empty
findAll = [User[id=1, name=An], User[id=2, name=Binh]]Compare that to the real thing. Spring Data's CrudRepository, decompiled from spring-data-commons 4.1.1, the version Boot 4.1.1 resolves:
public interface org.springframework.data.repository.CrudRepository<T, ID> extends org.springframework.data.repository.Repository<T, ID> {
public abstract <S extends T> S save(S);
public abstract java.util.Optional<T> findById(ID);
public abstract boolean existsById(ID);
public abstract java.lang.Iterable<T> findAll();
public abstract long count();
public abstract void deleteById(ID);JpaRepository<User, Long> is that, several interfaces deep. So when you write interface UserRepository extends JpaRepository<User, Long>, you are saying entity is User, primary key is Long — and every inherited method's signature is rewritten accordingly. findById returns Optional<User> and takes a Long, because you said so.
ResponseEntity<T> reads the same way: the parameter is the body type, so the compiler knows what getBody() gives back without a cast.
ResponseEntity<User> response = ResponseEntity.ok(new User(1L, "An"));
String name = response.getBody().name();
System.out.println("status : " + response.getStatusCode() + ", body name = " + name);status : 200 OK, body name = AnA bounded type parameter restricts what can be substituted. <T extends Number> means "any type that is a Number", which is what lets the method body call Number methods:
static <T extends Number> double sum(List<T> values) {
double total = 0;
for (T n : values) total += n.doubleValue();
return total;
}sum(int) = 6.0
sum(double) = 4.0Type erasure, and the one place it leaks
Generics exist for the compiler. After compilation the type arguments are erased, and at runtime there is only List:
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println("erasure: a.getClass() == b.getClass() -> " + (a.getClass() == b.getClass()));
System.out.println("erasure: both are " + a.getClass().getName());erasure: a.getClass() == b.getClass() -> true
erasure: both are java.util.ArrayListThat is why two overloads that differ only by type argument do not compile — after erasure they are the same method:
class Clash {
void save(List<String> names) {}
void save(List<Integer> ids) {}
}Clash.java:5: error: name clash: save(List<Integer>) and save(List<String>) have the same erasure
void save(List<Integer> ids) {}
^Erasure becomes Spring's problem when a framework has to deserialize into a generic type. Given only List.class, an HTTP client cannot know the elements should become User objects. Spring's answer is ParameterizedTypeReference: you create an anonymous subclass, which records its generic superclass in the class file, and the type survives:
List<User> users = new ArrayList<>();
System.out.println("erased : " + users.getClass().getName());
ParameterizedTypeReference<List<User>> ref = new ParameterizedTypeReference<>() {};
System.out.println("retained : " + ref.getType());erased : java.util.ArrayList
retained : java.util.List<User>The trailing {} is doing all the work — it makes an anonymous subclass, and a subclass's generic supertype is one of the few generic facts the class file keeps. Without it, the type argument is gone.
Lambda, functional interfaces and Stream
A functional interface is an interface with exactly one abstract method. A lambda is an instance of one, written inline. java.util.function ships the four shapes you will meet constantly:
| Interface | Method | Meaning | Lambda |
|---|---|---|---|
Function<T,R> | R apply(T) | takes one, returns another | s -> s.length() |
Supplier<T> | T get() | takes nothing, produces one | () -> new Order(...) |
Consumer<T> | void accept(T) | takes one, returns nothing | o -> System.out.println(o) |
Predicate<T> | boolean test(T) | takes one, answers yes or no | o -> o.total() > 100 |
Function<String, Integer> length = s -> s.length();
Function<String, Integer> lengthRef = String::length;
Supplier<Order> fallback = () -> new Order("SKU-0", 0, "NONE");
Consumer<String> log = System.out::println;
Predicate<Order> paid = o -> o.status().equals("PAID");
System.out.println("Function : " + length.apply("Spring") + " / " + lengthRef.apply("Spring"));
System.out.println("Supplier : " + fallback.get());
System.out.println("Predicate : " + paid.test(new Order("SKU-1", 120, "PAID")));
log.accept("Consumer : written by System.out::println");Function : 6 / 6
Supplier : Order[sku=SKU-0, total=0, status=NONE]
Predicate : true
Consumer : written by System.out::printlnString::length and System.out::println are method references — shorthand for a lambda that does nothing but call one method. There are four forms:
| Form | Example | Equivalent lambda |
|---|---|---|
| static method | Integer::parseInt | s -> Integer.parseInt(s) |
| instance method of a particular object | System.out::println | x -> System.out.println(x) |
| instance method of the parameter | String::length | s -> s.length() |
| constructor | ArrayList::new | () -> new ArrayList<>() |
Your own interface works exactly the same way; @FunctionalInterface is optional but makes the compiler enforce the single-method rule:
@FunctionalInterface
interface Discount {
int apply(int amount);
}
Discount flat = amount -> amount - 10;
System.out.println("Custom FI : " + flat.apply(100));Custom FI : 90Spring takes lambdas in the same two ways. As a supplier of a value or an exception — orElseThrow(() -> new UserNotFoundException(id)) builds the exception only if it is actually needed — and as a configuration callback, where a method hands you a builder and you mutate it: cfg -> cfg.something(...). Both are ordinary functional interfaces; nothing about them is Spring-specific.
A stream pipeline, stage by stage
A stream is a pipeline of operations over a source. Intermediate operations (filter, map, sorted) return another stream and do nothing on their own; a terminal operation (toList, sum, forEach) is what makes the whole thing run.
List<Order> orders = List.of(
new Order("SKU-1", 120, "PAID"),
new Order("SKU-2", 40, "PAID"),
new Order("SKU-3", 300, "CANCELLED"),
new Order("SKU-4", 250, "PAID"));
System.out.println("-- pipeline built, nothing has run yet --");
var pipeline = orders.stream()
.peek(o -> System.out.println("source -> " + o.sku()))
.filter(o -> o.status().equals("PAID"))
.peek(o -> System.out.println(" paid -> " + o.sku()))
.filter(o -> o.total() >= 100)
.peek(o -> System.out.println(" >=100 -> " + o.sku()))
.map(Order::sku);
System.out.println("-- terminal operation starts here --");
List<String> result = pipeline.toList();
System.out.println("result = " + result);-- pipeline built, nothing has run yet --
-- terminal operation starts here --
source -> SKU-1
paid -> SKU-1
>=100 -> SKU-1
source -> SKU-2
paid -> SKU-2
source -> SKU-3
source -> SKU-4
paid -> SKU-4
>=100 -> SKU-4
result = [SKU-1, SKU-4]
Two things in that trace are worth more than the syntax. Nothing printed between "pipeline built" and "terminal operation starts here" — the intermediate operations had not run at all. And the order is not four passes over the list: SKU-1 went all the way to the end before SKU-2 was even read, and SKU-3 stopped at the first filter, so the second filter never saw it.
mapToInt plus sum is the other pipeline you will write weekly:
int revenue = orders.stream()
.filter(o -> o.status().equals("PAID"))
.mapToInt(Order::total)
.sum();revenue = 410In a Spring application this is almost always the same job: take the List<UserEntity> a repository returned and produce a List<UserResponse> for the caller.
Optional: the return type of findById
Optional<T> is a box holding either a value or nothing. It exists so that "no result" is expressed in the return type instead of as a null the caller forgets to check. Spring Data uses it for exactly that: Optional<T> findById(ID).
| Method | Returns | Use it when |
|---|---|---|
isPresent() / isEmpty() | boolean | you only need the question answered |
get() | T, or throws | almost never — see below |
orElse(other) | T | you have a cheap fallback value |
orElseGet(supplier) | T | the fallback is expensive to build |
orElseThrow(supplier) | T, or throws yours | the caller must fail if it is missing |
map(fn) | Optional<R> | you want one field out of the value |
filter(pred) | Optional<T> | empty unless the value also matches |
ifPresent(consumer) | void | do something only when present |
static Optional<User> findById(Long id) {
return Optional.ofNullable(ROWS.get(id));
}
User user = findById(1L).orElseThrow(() -> new NoSuchElementException("user 1 not found"));
String email = findById(99L).map(User::email).orElse("(none)");
findById(1L).ifPresent(u -> System.out.println("ifPresent : " + u.email()));
findById(99L).ifPresent(u -> System.out.println("never printed"));present : Optional[User[id=1, name=An, email=an@example.com]]
missing : Optional.empty
orElseThrow ok : An
map + orElse : (none)
ifPresent : an@example.com
filter : Optional.emptyorElseThrow with a lambda is the workhorse in a service layer: it unwraps the value or raises the exception you want the HTTP layer to translate.
findById(99L).orElseThrow(() -> new IllegalStateException("user 99 not found"));orElseThrow : java.lang.IllegalStateException: user 99 not foundThree ways to misuse Optional
Calling get() without checking. This is a NullPointerException with extra steps:
findById(99L).get();get() on empty : java.util.NoSuchElementException: No value presentUsing isPresent() then get(). It compiles and it works, but it is the null check you were trying to escape, now three lines long. map, orElse and orElseThrow say the same thing in one.
Putting Optional in a field or a parameter. It was designed as a return type. As a field it costs an extra object per instance and breaks serialization outright — Optional does not implement Serializable:
new ObjectOutputStream(new ByteArrayOutputStream()).writeObject(Optional.of("x"));as a field : NotSerializableException: java.util.OptionalFor a parameter, an overload or a plain nullable argument is clearer. Return Optional, store and pass the value.
Record: the DTO shape Spring binds JSON to
A record is an immutable carrier of data. One line declares the fields, a canonical constructor, an accessor per component, and equals, hashCode and toString:
record CreateUserRequest(String name, String email, int age) {
CreateUserRequest {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
name = name.trim();
}
}The body there is a compact constructor — no parameter list, no assignments. It runs before the fields are assigned, so it can validate and it can normalise by reassigning the parameter, as the name.trim() line does.
toString : CreateUserRequest[name=An, email=an@example.com, age=30]
accessor : An / an@example.com
equals : true
hashCode : true
compact : IllegalArgumentException: name must not be blankNote the accessor name: req.name(), not req.getName(). Records do not follow the JavaBean convention.
Immutability is enforced by the compiler, not by convention. The fields are final, and the class itself is final:
record Point(int x, int y) {
void moveRight() {
this.x = x + 1;
}
}RecordFinal.java:3: error: cannot assign a value to final variable x
this.x = x + 1;
^RecordInherit.java:3: error: cannot inherit from final Point3
class Sub extends Point3 {
^That is exactly what a DTO wants: a value that arrives from a request, is validated once and never changes afterwards. Jackson — version 3.1.5, the one Boot 4.1.1 manages — binds records directly, using the canonical constructor to deserialize:
ObjectMapper mapper = JsonMapper.builder().build();
String json = mapper.writeValueAsString(req);
CreateUserRequest back = mapper.readValue(
"{\"name\":\"Binh\",\"email\":\"binh@example.com\",\"age\":25}",
CreateUserRequest.class);serialize : {"name":"An","email":"an@example.com","age":30}
deserialize : CreateUserRequest[name=Binh, email=binh@example.com, age=25]
round trip : trueThat round trip is the whole reason records took over DTOs. The same class that models a request body is the class the JSON parser can construct, with no setters and nothing to keep in sync.
record | class with getters/setters | |
|---|---|---|
| Lines for three fields | 1 | roughly 40, or a Lombok annotation |
| Mutable after construction | no | yes |
equals / hashCode | generated from all components | written or generated by a tool |
| Validation | compact constructor | constructor or setters |
Fits a JPA @Entity | no | yes |
That last row is the one to remember. JPA requires a no-argument constructor and non-final fields, so a record cannot be an entity. Use records for the DTOs at the edges of the application and a normal class for the entity in the middle.
Annotation and reflection: why Spring is not magic
This is the section that matters. An annotation is inert metadata. It attaches information to a declaration and changes nothing by itself. Something else has to read it and act — and once you have written that reader yourself, @Component and @GetMapping stop being mysterious.
Declaring an annotation
An annotation type is declared with @interface, and its own two annotations decide where it may go and how long it survives:
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Handler {
String value();
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Route {
String path();
String method() default "GET";
}value() is special: an annotation whose only element is called value can be written @Handler("/users") instead of @Handler(value = "/users"). Elements with a default may be omitted.
Now put them on a class. Two further annotations are attached on purpose, with weaker retentions, to prove the point in a moment:
@Handler("/users")
@DevNote("retention SOURCE - discarded by javac")
@BuildOnly("retention CLASS - in the class file, not loaded at runtime")
public class UserController {
@Route(path = "/{id}")
public String findOne(String id) {
return "user " + id;
}
@Route(path = "", method = "POST")
public String create(String body) {
return "created " + body;
}
public String notARoute() {
return "never mapped";
}
}Calling the class directly proves the annotations do nothing:
-- the annotation on its own changes nothing --
user 7No route was mapped, no dispatch happened, nothing was intercepted. It is a normal method call on a normal object.
Reading it back with reflection
Reflection is the API for inspecting a class at runtime. Fifteen lines turn the metadata above into a working route table:
Class<?> type = UserController.class;
if (!type.isAnnotationPresent(Handler.class)) {
System.out.println(type.getSimpleName() + " has no @Handler, skipped");
return;
}
String base = type.getAnnotation(Handler.class).value();
Object instance = type.getDeclaredConstructor().newInstance();
Map<String, Method> routes = new LinkedHashMap<>();
Method[] methods = type.getDeclaredMethods();
Arrays.sort(methods, Comparator.comparing(Method::getName));
for (Method m : methods) {
Route route = m.getAnnotation(Route.class);
if (route == null) continue;
routes.put(route.method() + " " + base + route.path(), m);
}
routes.forEach((key, m) -> System.out.println("mapped " + key + " -> " + m.getName() + "()"));
System.out.println("GET /users/{id} => " + routes.get("GET /users/{id}").invoke(instance, "42"));
System.out.println("POST /users => " + routes.get("POST /users").invoke(instance, "{\"name\":\"An\"}"));-- route table built by reading annotations --
mapped POST /users -> create()
mapped GET /users/{id} -> findOne()
-- dispatch --
GET /users/{id} => user 42
POST /users => created {"name":"An"}That is the entire trick. getAnnotation reads the metadata, getDeclaredMethods enumerates candidates, newInstance constructs the object and invoke calls a method chosen at runtime rather than at compile time. notARoute() was never mapped because it carries no @Route. Scale this up — scan a package instead of one class, cache the table, handle parameters and return values — and you have the outline of what a web framework does at startup.
What RetentionPolicy actually controls
@Retention is a filter with two gates, and it is the single most common reason a custom annotation "does nothing".
| Policy | In the source | In the class file | Visible to reflection |
|---|---|---|---|
SOURCE | yes | no | no |
CLASS (the default) | yes | yes | no |
RUNTIME | yes | yes | yes |

UserController carries three annotations with three different policies. At runtime, only one of them exists:
System.out.println(Arrays.toString(UserController.class.getAnnotations()));[@Handler("/users")]@BuildOnly is not gone, though — it is in the class file, just in an attribute the class loader does not expose. javap -v shows both attributes side by side:
RuntimeVisibleAnnotations:
0: #31(#32=s#33)
Handler(
value="/users"
)
RuntimeInvisibleAnnotations:
0: #35(#32=s#36)
BuildOnly(
value="retention CLASS - in the class file, not loaded at runtime"
)@DevNote is not in either attribute. Searching the class file for the annotation type descriptors finds only three of the four:
strings UserController.class | grep -o -E 'L(Handler|Route|DevNote|BuildOnly);' | sort -uLBuildOnly;
LHandler;
LRoute;javac deleted it. That is the intended use of SOURCE: @Override and @SuppressWarnings are compile-time checks with nothing to say afterwards, so they leave no trace.
⚠️ The default retention is
CLASS, notRUNTIME. An annotation you plan to read reflectively and declare without@Retentioncompiles fine, ships fine, andgetAnnotations()silently returns nothing for it.
Spring's own annotations are built the same way
Nothing above is special to a toy example. Reflecting on the real annotations from Spring Framework 7.0.9 shows the same two decisions — a RUNTIME retention, and composition through meta-annotations:
for (Class<?> a : new Class<?>[] { Component.class, Service.class, RestController.class, GetMapping.class }) {
Retention r = a.getAnnotation(Retention.class);
// ... collect a.getAnnotations(), skipping the java.lang.annotation.* ones
System.out.printf("@%-15s retention=%-8s meta-annotated with: %s%n", a.getSimpleName(), r.value(), meta);
}@Component retention=RUNTIME meta-annotated with: @Indexed
@Service retention=RUNTIME meta-annotated with: @Component
@RestController retention=RUNTIME meta-annotated with: @Controller, @ResponseBody
@GetMapping retention=RUNTIME meta-annotated with: @RequestMappingEvery one of them is RUNTIME, because Spring reads them reflectively at startup — exactly as the route table above did. And an annotation can carry annotations of its own: @Service is a @Component, @RestController is a @Controller plus @ResponseBody. That composition is why Spring can treat a class marked @Service as a component without @Service needing any special case in the container.
You can check the same relationship on your own types:
System.out.println("direct @Stereotype? " + type.isAnnotationPresent(Stereotype.class));
for (Annotation a : type.getAnnotations()) {
System.out.println(a.annotationType().getSimpleName() + " meta-annotated @Stereotype? "
+ a.annotationType().isAnnotationPresent(Stereotype.class));
}direct @Stereotype? false
Handler meta-annotated @Stereotype? trueUserController is not annotated @Stereotype, but the annotation it is annotated with carries it — so a scanner looking one level up finds it. That single idea, applied at startup across every class on the classpath, is most of what Spring Boot does before your code runs.
What you should be able to do before article 2
| You should be able to | Check |
|---|---|
| write an interface and two implementations, and a caller that names only the interface | the caller has no new for either implementation |
choose between List, Set and Map and know which are immutable | List.of(...) throws on add |
read Repository<T, ID> and say what T and ID are in JpaRepository<User, Long> | findById returns Optional<User> |
write a lambda for Function, Supplier, Consumer and Predicate | and the method-reference form of each |
| build a stream pipeline and name its terminal operation | nothing runs without one |
unwrap an Optional without calling get() | map, orElse, orElseThrow |
| write a record with a compact constructor | fields are final, accessors have no get prefix |
declare a RUNTIME annotation and read it back reflectively | getAnnotation then invoke |
If the last row is solid, the rest of this series is configuration and API surface rather than mystery.
FAQ
Do I need to know Java 21 specifically, or will Java 17 do?
Spring Boot 4.1.1 requires Java 17 at minimum — its class files are major version 61 — so 17 runs everything in this series. Java 21 is used here because it is the current LTS and because records, var and Stream.toList() are all stable in it. Nothing in the series depends on a Java 21-only feature.
Why does my custom annotation do nothing?
Two causes, in this order. Either the retention is not RUNTIME, so reflection cannot see it — the default is CLASS, which is invisible at runtime. Or nothing reads it: an annotation on its own never changes behaviour, and something has to call getAnnotation and act on the result. Inside Spring, that reader is the container at startup, and it only looks at classes it scans.
Is reflection slow enough to worry about?
For the work described here, no. Spring pays the reflection cost once at startup while building its metadata, not on every request. Reflection in a tight loop in your own code is a different matter — cache the Method object instead of looking it up each time.
Should I use a record or a class for my entity?
A class. JPA requires a no-argument constructor and non-final fields, and a record has neither. Records fit the DTOs at the edges — request bodies and responses — while the entity in the middle stays a normal class.
What is the difference between Optional.orElse and Optional.orElseGet?
orElse takes a value, so the argument is evaluated whether or not it is needed. orElseGet takes a Supplier, so the fallback is built only when the Optional is empty. Use orElse for a constant and orElseGet whenever producing the fallback costs anything — a database call, an object graph, a new list.
Why does Spring need ParameterizedTypeReference when generics already exist?
Because generics are erased. At runtime a List<User> is just a List, so a client that has to build objects from a response body has nothing to build them from. ParameterizedTypeReference is created as an anonymous subclass, and a subclass's generic supertype is kept in the class file, so getType() can still report java.util.List<User>.
Conclusion
Nothing in this list is Spring. Interfaces give the framework somewhere to substitute an implementation, generics let one repository interface serve every entity, Optional makes "not found" a return type, records make a DTO a single line, and annotations plus reflection are the mechanism by which a framework reads your code and acts on it. The route table built above is a fifty-line sketch of what Spring does across an entire classpath at startup.
The next article is about the framework itself: how Spring Framework and Spring Boot differ, what auto-configuration actually does, what a starter contains, and where the embedded server comes from.