Command Palette

Search for a command to run...

[Spring Boot Basics] Java Prerequisites for Spring Boot: OOP, Generics, Streams, Records and Annotations

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.

Spring Boot resting on six ordinary Java features: interface, List, lambda, Optional, record and annotation

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.

ChoiceWhy it matters here
FrameworkSpring Boot 4.1.1pulls in Spring Framework 7.0.9
LanguageJava 21 (LTS)records, var, pattern matching, Stream.toList() all available
Build toolGradle, via the ./gradlew wrapperno Gradle install needed; Maven is compared later in the series
Minimum JDK17Boot 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:

Bash
javap -v -cp spring-boot-4.1.1.jar org.springframework.boot.SpringApplication | grep 'major version'
Text
  major version: 61

Java 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 featureWhere you meet it in Spring
interface + polymorphisma service field typed as an interface, with the implementation chosen elsewhere
List, Set, MapList<SomeInterface> holding every implementation; Map<String, T> keyed by name
genericsJpaRepository<User, Long>, ResponseEntity<T>, Optional<T>
lambda + functional interfacesorElseThrow(() -> ...), configuration written as cfg -> cfg.something()
Streamturning a list of entities into a list of DTOs
Optionalthe return type of findById
recordrequest 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.

Java
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:

Java
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.

Java
new OrderService(new EmailNotifier()).placeOrder("an@example.com", "SKU-1");
new OrderService(new SmsNotifier()).placeOrder("+84900000001", "SKU-2");
Text
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

A caller depending on the Notifier interface, with EmailNotifier and SmsNotifier behind it, and the concrete class chosen outside the caller

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:

ConstructCan hold stateCan have behaviourA class can have
interfaceno (only constants)default and static methodsmany
abstract classyesyes, including constructorsexactly one superclass
recordyes, immutable onlyyes, but no extra fieldsit 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.

InterfaceGuaranteesUsual implementationTypical use in a Spring app
List<E>ordered, duplicates allowed, indexedArrayListrows returned from a repository
Set<E>no duplicates, order not guaranteedHashSet, LinkedHashSetroles, tags, unique ids
Map<K,V>key to value, one entry per keyHashMap, LinkedHashMaplookup tables, request parameters
Java
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));
Text
list     = [ADMIN, USER, USER]  size=3
set      = [ADMIN, USER]  size=2
map      = {ADMIN=1000, USER=50}
get USER = 50
get NONE = null
getOrDef = 0

Two 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:

Java
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");
Text
byChannel keys = [sms, email]
[sms] +84900000001 ping

Note 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:

Java
List.of("a").add("b");
Text
List.of is immutable -> java.lang.UnsupportedOperationException: null

The 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:

Java
List<String> names = new ArrayList<>();
names.add(42);
Text
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:

Java
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());
    }
}
Text
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:

Text
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.

Java
ResponseEntity<User> response = ResponseEntity.ok(new User(1L, "An"));
String name = response.getBody().name();
System.out.println("status   : " + response.getStatusCode() + ", body name = " + name);
Text
status   : 200 OK, body name = An

A 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:

Java
static <T extends Number> double sum(List<T> values) {
    double total = 0;
    for (T n : values) total += n.doubleValue();
    return total;
}
Text
sum(int)     = 6.0
sum(double)  = 4.0

Type 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:

Java
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());
Text
erasure: a.getClass() == b.getClass() -> true
erasure: both are java.util.ArrayList

That is why two overloads that differ only by type argument do not compile — after erasure they are the same method:

Java
class Clash {
    void save(List<String> names) {}
    void save(List<Integer> ids) {}
}
Text
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:

Java
List<User> users = new ArrayList<>();
System.out.println("erased   : " + users.getClass().getName());
 
ParameterizedTypeReference<List<User>> ref = new ParameterizedTypeReference<>() {};
System.out.println("retained : " + ref.getType());
Text
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:

InterfaceMethodMeaningLambda
Function<T,R>R apply(T)takes one, returns anothers -> s.length()
Supplier<T>T get()takes nothing, produces one() -> new Order(...)
Consumer<T>void accept(T)takes one, returns nothingo -> System.out.println(o)
Predicate<T>boolean test(T)takes one, answers yes or noo -> o.total() > 100
Java
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");
Text
Function  : 6 / 6
Supplier  : Order[sku=SKU-0, total=0, status=NONE]
Predicate : true
Consumer  : written by System.out::println

String::length and System.out::println are method references — shorthand for a lambda that does nothing but call one method. There are four forms:

FormExampleEquivalent lambda
static methodInteger::parseInts -> Integer.parseInt(s)
instance method of a particular objectSystem.out::printlnx -> System.out.println(x)
instance method of the parameterString::lengths -> s.length()
constructorArrayList::new() -> new ArrayList<>()

Your own interface works exactly the same way; @FunctionalInterface is optional but makes the compiler enforce the single-method rule:

Java
@FunctionalInterface
interface Discount {
    int apply(int amount);
}
 
Discount flat = amount -> amount - 10;
System.out.println("Custom FI : " + flat.apply(100));
Text
Custom FI : 90

Spring takes lambdas in the same two ways. As a supplier of a value or an exceptionorElseThrow(() -> 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.

Java
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);
Text
-- 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]

The four orders traced through filter, filter and map, showing which survive each stage and the interleaved order the stages actually run in

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:

Java
int revenue = orders.stream()
        .filter(o -> o.status().equals("PAID"))
        .mapToInt(Order::total)
        .sum();
Text
revenue = 410

In 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).

MethodReturnsUse it when
isPresent() / isEmpty()booleanyou only need the question answered
get()T, or throwsalmost never — see below
orElse(other)Tyou have a cheap fallback value
orElseGet(supplier)Tthe fallback is expensive to build
orElseThrow(supplier)T, or throws yoursthe 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)voiddo something only when present
Java
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"));
Text
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.empty

orElseThrow 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.

Java
findById(99L).orElseThrow(() -> new IllegalStateException("user 99 not found"));
Text
orElseThrow     : java.lang.IllegalStateException: user 99 not found

Three ways to misuse Optional

Calling get() without checking. This is a NullPointerException with extra steps:

Java
findById(99L).get();
Text
get() on empty  : java.util.NoSuchElementException: No value present

Using 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:

Java
new ObjectOutputStream(new ByteArrayOutputStream()).writeObject(Optional.of("x"));
Text
as a field      : NotSerializableException: java.util.Optional

For 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:

Java
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.

Text
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 blank

Note 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:

Java
record Point(int x, int y) {
    void moveRight() {
        this.x = x + 1;
    }
}
Text
RecordFinal.java:3: error: cannot assign a value to final variable x
        this.x = x + 1;
            ^
Text
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:

Java
ObjectMapper mapper = JsonMapper.builder().build();
String json = mapper.writeValueAsString(req);
 
CreateUserRequest back = mapper.readValue(
        "{\"name\":\"Binh\",\"email\":\"binh@example.com\",\"age\":25}",
        CreateUserRequest.class);
Text
serialize   : {"name":"An","email":"an@example.com","age":30}
deserialize : CreateUserRequest[name=Binh, email=binh@example.com, age=25]
round trip  : true

That 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.

recordclass with getters/setters
Lines for three fields1roughly 40, or a Lombok annotation
Mutable after constructionnoyes
equals / hashCodegenerated from all componentswritten or generated by a tool
Validationcompact constructorconstructor or setters
Fits a JPA @Entitynoyes

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:

Java
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:

Java
@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:

Text
-- the annotation on its own changes nothing --
user 7

No 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:

Java
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\"}"));
Text
-- 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".

PolicyIn the sourceIn the class fileVisible to reflection
SOURCEyesnono
CLASS (the default)yesyesno
RUNTIMEyesyesyes

An annotation moving from source through javac to the class file and into reflection, with SOURCE dropped at compilation and CLASS dropped before runtime

UserController carries three annotations with three different policies. At runtime, only one of them exists:

Java
System.out.println(Arrays.toString(UserController.class.getAnnotations()));
Text
[@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:

Text
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:

Bash
strings UserController.class | grep -o -E 'L(Handler|Route|DevNote|BuildOnly);' | sort -u
Text
LBuildOnly;
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, not RUNTIME. An annotation you plan to read reflectively and declare without @Retention compiles fine, ships fine, and getAnnotations() 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:

Java
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);
}
Text
@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: @RequestMapping

Every 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:

Java
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));
}
Text
direct @Stereotype? false
Handler meta-annotated @Stereotype? true

UserController 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 toCheck
write an interface and two implementations, and a caller that names only the interfacethe caller has no new for either implementation
choose between List, Set and Map and know which are immutableList.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 Predicateand the method-reference form of each
build a stream pipeline and name its terminal operationnothing runs without one
unwrap an Optional without calling get()map, orElse, orElseThrow
write a record with a compact constructorfields are final, accessors have no get prefix
declare a RUNTIME annotation and read it back reflectivelygetAnnotation 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.

Related Posts

[Spring Boot Basics] Logging in Spring Boot: SLF4J, Logback, Log Levels and Log Files

Logging in Spring Boot 4.1.1, verified on a real project: SLF4J as the facade and Logback 1.5.38 as the implementation, the jul-to-slf4j and log4j-to-slf4j bridges, parameterised and fluent logging, exceptions, log levels, the logger hierarchy and log groups, --debug versus --trace, the default log line pattern, logging.file.name with rotation, logback-spring.xml with springProfile, MDC and switching to Log4j2.

[Spring Boot Basics] API Documentation in Spring Boot with springdoc-openapi and Swagger UI

springdoc-openapi 3.1.1 on Spring Boot 4.1.1, checked on a running jar: the OpenAPI 3.1 document at /v3/api-docs, Swagger UI and Try it out, what springdoc infers from controllers, DTO records and Bean Validation constraints, which @RestControllerAdvice responses it adds, @Tag, @Operation, @ApiResponse, @Parameter and @Schema on records, a global OpenAPI bean and customizer, GroupedOpenApi, springdoc properties and switching the docs off in a prod profile.

[Spring Boot Basics] Layered Architecture in Spring Boot: Controller, Service, Repository and Package by Layer vs by Feature

Layered architecture in Spring Boot 4.1.1, refactored and verified on a real catalogue API: the controller that does everything and the startup crash when it is reused, what controller, service and repository each own, where DTO mapping belongs, a step-by-step refactor proven unchanged with curl, a service interface or a concrete class checked with Mockito 5.23, five layering anti-patterns, and package by layer versus package by feature measured on one change, with package-private beans injected and the compiler error that keeps features apart.

[Spring Boot Basics] Server-Side Rendering with Thymeleaf in Spring Boot: Templates, Forms and Validation

Server-side rendering with Thymeleaf 3.1.5 in Spring Boot 4.1.1, checked on a running application: when SSR beats a JSON API, how a view name becomes classpath:/templates/products/list.html, the five standard expressions, th:text versus th:utext and XSS, th:each with #numbers and #temporals, a validated create form with th:field and th:errors, where BindingResult must go, Post/Redirect/Get with flash attributes, fragments, static CSS, what spring.thymeleaf.cache really changes, and HTML error pages.