Day 1: Spring Boot Basics

The core ideas: beans, configuration, REST controllers, validation, database access, calling other services and testing all of it.

Subsections of Day 1: Spring Boot Basics

Why Spring Boot?

The problem it solves

Plain Java gives you a language, not an application. A service needs an HTTP server, JSON mapping, a connection pool, transactions, config per environment, health checks, metrics and tests. Spring Boot wires all of that from a dependency list and sensible defaults.

@RestController
class Api {
    @GetMapping("/ping")
    String ping() { return "pong"; }
}

One dependency and four lines is a running HTTP service.

The three ideas

IdeaWhat it means
Inversion of controlyou declare components, the container creates and connects them
Auto-configurationclasspath contents decide what gets configured
Startersone dependency per topic, versions pinned by the parent

Every default is replaceable: define your own bean, and the auto-configured one backs off.

The ecosystem

ProjectFor
Spring Framework 7core container, MVC/WebFlux, transactions
Spring DataJPA, MongoDB, Redis repositories
Spring Securityauthentication and authorization
Spring Boot Actuatorhealth, metrics, observability
Spring Batch / Integration / Kafkajobs, pipelines, messaging

What is new in Spring Boot 4

  • Modularization — many small jars instead of one spring-boot-autoconfigure
  • Null safety — the whole portfolio is annotated with JSpecify
  • API versioning — first-class support in Spring MVC and WebFlux
  • HTTP service clients — declare an interface, get an implementation
  • Java 25 support — with Java 17 still the baseline
  • Resilience@Retryable and @ConcurrencyLimit moved into the core framework
  • RestTestClient — one test client for mock and live servers

Built on Spring Framework 7, Jakarta EE 11, Jackson 3 and Kotlin 2.2.

Coming from Spring Boot 3?

Most code compiles unchanged. Watch for: Jackson 2 support deprecated, a few renamed properties (spring.dao.exceptiontranslation.enabledspring.persistence.exceptiontranslation.enabled, management.tracing.enabledmanagement.tracing.export.enabled), and @MockBean replaced by @MockitoBean.

When not to use it

  • a 200-line CLI tool — plain Java is lighter
  • hard real-time or tiny memory budgets — the container costs startup and RAM
  • a library that others embed — keep it framework-free

★ Exercises

  1. Name three things auto-configuration does for spring-boot-starter-web.
  2. Remove the web starter from a project. What still runs?
  3. Look up two more starters at start.spring.io and say what each adds.
  4. Which of the Boot 4 features above would change code you already have?

Beans & Dependency Injection

What a bean is

A bean is an object the container creates, configures and hands out. You never call new on it yourself.

@Service
public class GreetingService {
    public String greet(String name) {
        return "Hello, " + name + "!";
    }
}
StereotypeMeaning
@Componentany bean
@Servicebusiness logic
@Repositorydata access (adds exception translation)
@RestControllerweb endpoint returning data
@Configurationclass that declares @Bean methods

All four of the first are @Component underneath — the name documents intent.

Constructor injection

@RestController
public class GreetingController {

    private final GreetingService service;

    public GreetingController(GreetingService service) {   // no @Autowired needed
        this.service = service;
    }

    @GetMapping("/greet/{name}")
    String greet(@PathVariable String name) {
        return service.greet(name);
    }
}

One constructor, final fields, no framework annotation in sight — that class is testable with plain new.

Avoid field injection

@Autowired private Foo foo; hides dependencies, breaks final, and forces reflection in tests. Use the constructor.

Beans from a configuration class

Use @Bean for types you do not own:

@Configuration
public class ClientConfig {

    @Bean
    RestClient restClient(RestClient.Builder builder) {
        return builder.baseUrl("https://api.example.com").build();
    }
}

The method name is the bean name; parameters are injected.

Choosing between candidates

public interface Notifier { void send(String msg); }

@Component @Primary  class EmailNotifier implements Notifier {  }
@Component("sms")    class SmsNotifier   implements Notifier {  }
Alerts(Notifier defaultOne,                       // EmailNotifier, it is @Primary
       @Qualifier("sms") Notifier urgent) {  }

Inject List<Notifier> or Map<String, Notifier> to get all of them.

Conditional beans

@Bean
@ConditionalOnMissingBean          // only if the user did not define one
@ConditionalOnProperty(name = "features.cache", havingValue = "true")
CacheManager cacheManager() {  }

This is exactly how auto-configuration backs off in favour of your own beans.

Scope and lifecycle

Beans are singletons by default — one instance per context, shared by every thread. Keep them stateless.

@Component
@Scope("prototype")        // new instance per injection point
class Ticket { }
@Component
class Warmup {
    @PostConstruct  void start() {  }   // after injection
    @PreDestroy     void stop()  {  }   // on shutdown
}

For startup work with access to arguments:

@Bean
ApplicationRunner seed(BookRepository repo) {
    return args -> repo.save(new Book("Dune"));
}

★ Exercises

  1. Write a RandomQuoteService and inject it into a controller.
  2. Add a second Notifier implementation and select one with @Qualifier.
  3. Inject List<Notifier> and call every implementation.
  4. Add a @PostConstruct log line and watch the order of construction on startup.
  5. Why does a singleton bean holding a mutable field cause bugs?

Configuration & Profiles

Where values come from

Spring reads many sources and lets later ones win:

  1. command line — --server.port=9000
  2. environment variables — SERVER_PORT=9000
  3. application-{profile}.properties
  4. application.properties
  5. defaults in code
java -jar app.jar --server.port=9000
SERVER_PORT=9000 java -jar app.jar

Relaxed binding maps server.port, SERVER_PORT and server-port to the same property.

Reading single values

@Component
class Mailer {
    private final String from;

    Mailer(@Value("${app.mail.from:noreply@example.com}") String from) {
        this.from = from;
    }
}

The part after : is the default.

Typed configuration

Better for anything with more than one key:

@ConfigurationProperties("app.mail")
public record MailProperties(String from, String host, int port, boolean tls) {}
@SpringBootApplication
@EnableConfigurationProperties(MailProperties.class)
public class DemoApplication {  }
app.mail.from=noreply@example.com
app.mail.host=smtp.example.com
app.mail.port=587
app.mail.tls=true

Inject MailProperties like any other bean. Records give you immutability and constructor binding for free.

Tip

Add spring-boot-configuration-processor as an optional dependency and your IDE autocompletes your own properties.

Validating configuration

@Validated
@ConfigurationProperties("app.mail")
public record MailProperties(@Email String from, @NotBlank String host,
                             @Min(1) @Max(65535) int port) {}

A bad value now fails at startup, not at the first send.

Profiles

A profile is a named set of configuration.

application.properties            always applied
application-dev.properties        only with the dev profile
application-prod.properties       only with the prod profile
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
java -jar app.jar --spring.profiles.active=prod
SPRING_PROFILES_ACTIVE=prod java -jar app.jar

Beans can be profile-specific:

@Bean
@Profile("!prod")
Notifier consoleNotifier() { return msg -> System.out.println(msg); }

Secrets

Never commit passwords. Pass them as environment variables and reference them:

spring.datasource.password=${DB_PASSWORD}
Warning

application.properties ends up inside the jar. Anything environment-specific or secret belongs outside it.

Useful built-in properties

PropertyEffect
server.portHTTP port, 0 = random
spring.application.namename in logs, metrics, traces
logging.level.<package>log level per package
logging.console.enablednew in Boot 4: turn console logging off
spring.threads.virtual.enabledvirtual threads for request handling
spring.jpa.hibernate.ddl-autoschema handling, none in production

★ Exercises

  1. Move a hard-coded string into application.properties and inject it with @Value.
  2. Convert it to a @ConfigurationProperties record with three keys.
  3. Add @Validated and make startup fail on an empty value.
  4. Create application-dev.properties with logging.level.com.example=DEBUG and run with that profile.
  5. Override the port three ways: file, environment variable, command line. Which wins?

REST Controllers

Mapping requests

@RestController
@RequestMapping("/api/books")
public class BookController {

    @GetMapping                 List<Book> all()                  {  }
    @GetMapping("/{id}")        Book one(@PathVariable long id)    {  }
    @PostMapping                Book create(@RequestBody Book b)   {  }
    @PutMapping("/{id}")        Book replace(@PathVariable long id, @RequestBody Book b) {  }
    @DeleteMapping("/{id}")     void delete(@PathVariable long id) {  }
}

@RestController = @Controller + @ResponseBody: return values are serialized, not resolved as view names.

Reading the request

AnnotationSourceExample
@PathVariableURL segment/books/7
@RequestParamquery string?page=2&size=20
@RequestBodyJSON bodyPOST payload
@RequestHeaderheaderAuthorization
@CookieValuecookiesession id
@GetMapping("/search")
List<Book> search(@RequestParam String q,
                  @RequestParam(defaultValue = "0") int page,
                  @RequestParam Optional<String> author) {  }

Records as DTOs

public record BookRequest(String title, String author, int year) {}
public record BookResponse(long id, String title, String author) {}

Keep web types separate from database entities — otherwise every column change is an API change.

Status codes and headers

@PostMapping
ResponseEntity<BookResponse> create(@RequestBody BookRequest req) {
    var saved = service.create(req);
    return ResponseEntity
            .created(URI.create("/api/books/" + saved.id()))   // 201 + Location
            .body(saved);
}

Or declare it:

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
BookResponse create(@RequestBody BookRequest req) {  }
SituationStatus
read ok200
created201
deleted, no body204
invalid input400
not authenticated / not allowed401 / 403
unknown id404
conflict, e.g. duplicate409

Content negotiation

@GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)

Jackson 3 handles JSON. Java time types serialize as ISO-8601 by default.

CORS

@CrossOrigin(origins = "https://app.example.com")
@RestController
class BookController {  }

Globally:

@Bean
WebMvcConfigurer cors() {
    return new WebMvcConfigurer() {
        @Override public void addCorsMappings(CorsRegistry reg) {
            reg.addMapping("/api/**").allowedOrigins("https://app.example.com");
        }
    };
}

MVC or WebFlux?

Spring MVCSpring WebFlux
modelone thread per requestevent loop, reactive
returnBook, List<Book>Mono<Book>, Flux<Book>
use whenthe normal casestreaming, very high concurrency

With virtual threads (spring.threads.virtual.enabled=true) blocking MVC code scales far enough for most services — start there.

Tip

Return ResponseEntity only when you need to control status or headers. Otherwise return the payload and keep the signature readable.

★ Exercises

  1. Build /api/books with an in-memory List and the five CRUD methods.
  2. Return 201 with a Location header on create.
  3. Add ?author= filtering with @RequestParam.
  4. Return 404 for an unknown id (ResponseEntity.notFound().build()).
  5. Add a second endpoint returning the same book as plain text.

Validation & Error Handling

Bean validation

Add spring-boot-starter-validation, annotate the DTO, and mark the parameter @Valid.

public record BookRequest(
        @NotBlank String title,
        @NotBlank @Size(max = 100) String author,
        @Min(1450) @Max(2100) int year,
        @Email String contact) {}
@PostMapping
BookResponse create(@Valid @RequestBody BookRequest req) {  }

A violation throws MethodArgumentNotValidException → 400 before your method runs.

AnnotationChecks
@NotNull / @NotBlank / @NotEmptypresent / non-blank text / non-empty collection
@Size(min, max)length or size
@Min / @Max / @Positivenumbers
@Email / @Patternformat
@Past / @Futuredates

Validate parameters too:

@Validated                                       // on the class
@GetMapping("/search")
List<Book> search(@RequestParam @NotBlank String q) {  }

Your own exceptions

public class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(long id) {
        super("No book with id " + id);
    }
}

One place for error responses

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    ProblemDetail notFound(BookNotFoundException ex) {
        var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        pd.setTitle("Book not found");
        return pd;
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail invalid(MethodArgumentNotValidException ex) {
        var pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setTitle("Validation failed");
        pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
                .collect(toMap(FieldError::getField, FieldError::getDefaultMessage)));
        return pd;
    }
}

@RestControllerAdvice applies to every controller.

RFC 9457 problem details

ProblemDetail is the standard error format:

{
  "type": "about:blank",
  "title": "Book not found",
  "status": 404,
  "detail": "No book with id 42",
  "instance": "/api/books/42"
}

Turn it on for the framework’s own errors too:

spring.mvc.problemdetails.enabled=true

Shortcut for simple cases

throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No book with id " + id);

Fine in small apps; a typed exception plus advice scales better.

Warning

Never return the stack trace to clients. Log it with the request id and send the client a short message.

Logging the failure

private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);

@ExceptionHandler(Exception.class)
ProblemDetail unexpected(Exception ex) {
    log.error("Unhandled exception", ex);
    return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Internal error");
}

★ Exercises

  1. Add validation to BookRequest and post an invalid body — read the response.
  2. Write BookNotFoundException and map it to 404 with ProblemDetail.
  3. Return field-level messages for validation errors.
  4. Add a catch-all handler that logs and returns 500 without details.
  5. Which of your endpoints should answer 409 instead of 400?

Data Access with JPA

Setup

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

With H2 on the classpath and no spring.datasource.url, Boot configures an in-memory database. For PostgreSQL:

spring.datasource.url=jdbc:postgresql://localhost:5432/demo
spring.datasource.username=demo
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate

An entity

@Entity
public class Book {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    private String author;
    private int year;

    protected Book() {}                       // required by JPA

    public Book(String title, String author, int year) {  }

    // getters, and setters only where they make sense
}
Warning

Entities are mutable objects tied to a persistence context — not DTOs. Records cannot be entities. Map to a record before returning data from a controller.

Repositories

public interface BookRepository extends JpaRepository<Book, Long> {

    List<Book> findByAuthor(String author);
    List<Book> findByYearGreaterThanOrderByYearDesc(int year);
    Optional<Book> findByTitleIgnoreCase(String title);
    boolean existsByTitle(String title);
    long countByAuthor(String author);
}

You write no implementation — Spring Data derives the query from the method name.

Inherited for free: save, saveAll, findById, findAll, delete, count, findAll(Pageable).

Own queries

@Query("select b from Book b where b.year between :from and :to")
List<Book> inRange(int from, int to);

@Query(value = "select * from book order by random() limit 1", nativeQuery = true)
Book random();

Projections keep result sets small:

public interface TitleOnly { String getTitle(); }

List<TitleOnly> findByAuthor(String author);

Paging and sorting

Page<Book> page = repo.findAll(PageRequest.of(0, 20, Sort.by("title")));
page.getContent(); page.getTotalElements(); page.getTotalPages();

Controllers can take a Pageable parameter directly: ?page=1&size=20&sort=title,asc.

Transactions

@Service
public class BookService {

    private final BookRepository repo;

    BookService(BookRepository repo) { this.repo = repo; }

    @Transactional
    public Book rename(long id, String title) {
        var book = repo.findById(id).orElseThrow(() -> new BookNotFoundException(id));
        book.setTitle(title);                 // dirty checking writes on commit
        return book;
    }

    @Transactional(readOnly = true)
    public List<Book> all() { return repo.findAll(); }
}

Rules: @Transactional belongs on the service, rolls back on unchecked exceptions, and only works when the call comes in from outside the bean (it is a proxy).

Schema management

SettingUse
spring.jpa.hibernate.ddl-auto=create-droptests, demos
…=updatelocal development, never production
…=validateproduction, with Flyway or Liquibase for migrations
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>

Migrations live in src/main/resources/db/migration/V1__init.sql.

The N+1 trap

@Query("select b from Book b join fetch b.reviews where b.author = :author")
List<Book> withReviews(String author);

Turn on spring.jpa.show-sql=true once and count the statements.

★ Exercises

  1. Make Book an entity and add a BookRepository.
  2. Seed three books with an ApplicationRunner.
  3. Add findByAuthor and expose it as /api/books?author=.
  4. Add paging to the list endpoint.
  5. Write a @Transactional rename method and check that a thrown exception rolls it back.
  6. Enable show-sql and look at what a findAll really executes.

Calling Other Services

RestClient

The synchronous client since Spring 6.1 — fluent, blocking, the default choice in MVC apps.

@Configuration
class ClientConfig {
    @Bean
    RestClient booksClient(RestClient.Builder builder) {
        return builder.baseUrl("https://api.example.com").build();
    }
}
Book book = client.get()
        .uri("/books/{id}", id)
        .retrieve()
        .body(Book.class);

List<Book> all = client.get()
        .uri("/books?author={a}", author)
        .retrieve()
        .body(new ParameterizedTypeReference<>() {});

Book created = client.post()
        .uri("/books")
        .contentType(MediaType.APPLICATION_JSON)
        .body(new BookRequest("Dune", "Herbert", 1965))
        .retrieve()
        .body(Book.class);

Handling failures:

var book = client.get().uri("/books/{id}", id)
        .retrieve()
        .onStatus(HttpStatusCode::is4xxClientError,
                  (req, res) -> { throw new BookNotFoundException(id); })
        .body(Book.class);
Note

RestTemplate still works but is in maintenance mode. New code uses RestClient (blocking) or WebClient (reactive).

HTTP service clients

New in Spring Boot 4: describe the remote API as an interface, let Spring implement it.

package com.example.clients;

@HttpExchange("/books")
public interface BookApi {

    @GetExchange("/{id}")
    Book byId(@PathVariable long id);

    @GetExchange
    List<Book> search(@RequestParam String author);

    @PostExchange
    Book create(@RequestBody BookRequest request);
}

Register the package and give the group a name:

@SpringBootApplication
@ImportHttpServices(group = "books", basePackages = "com.example.clients")
public class DemoApplication {  }

Configure the group instead of hard-coding URLs:

spring.http.clients.connect-timeout=1s
spring.http.serviceclient.books.base-url=https://api.example.com
spring.http.serviceclient.books.connect-timeout=2s
spring.http.serviceclient.books.read-timeout=2s

Then inject BookApi anywhere — no implementation class in your code base.

@Service
class Catalog {
    private final BookApi api;
    Catalog(BookApi api) { this.api = api; }

    Book fetch(long id) { return api.byId(id); }
}

Customize a group programmatically when properties are not enough:

@Bean
RestClientHttpServiceGroupConfigurer headers() {
    return groups -> groups.forEachClient((group, builder) ->
            builder.defaultHeader("X-Client", group.name()));
}

Timeouts

Always set them. A client without a read timeout turns a slow dependency into an outage.

spring.http.clients.connect-timeout=1s
spring.http.clients.read-timeout=5s

WebClient

Reactive and non-blocking; needs spring-boot-starter-webflux.

Mono<Book> book = webClient.get()
        .uri("/books/{id}", id)
        .retrieve()
        .bodyToMono(Book.class);

Use it for streaming responses or when you are already reactive.

Testing a client

@RestClientTest(Catalog.class)
class CatalogTest {

    @Autowired Catalog catalog;
    @Autowired MockRestServiceServer server;

    @Test
    void fetchesBook() {
        server.expect(requestTo("/books/1"))
              .andRespond(withSuccess("""
                  {"id":1,"title":"Dune"}""", MediaType.APPLICATION_JSON));

        assertThat(catalog.fetch(1).title()).isEqualTo("Dune");
    }
}

★ Exercises

  1. Call https://api.github.com/users/spring-projects with RestClient and print the name.
  2. Turn that call into an @HttpExchange interface and register it with @ImportHttpServices.
  3. Move the base URL into spring.http.serviceclient.<group>.base-url.
  4. Set a 2 s read timeout and point the client at a slow endpoint — what exception do you get?
  5. Map a 404 from the remote API to your own exception.

Testing

The starter

spring-boot-starter-test is in every generated project and brings JUnit 5, AssertJ, Mockito, JSONassert and the Spring test support.

./mvnw test

Plain unit tests

Constructor injection means most classes need no Spring at all.

class GreetingServiceTest {

    private final GreetingService service = new GreetingService();

    @Test
    void greetsByName() {
        assertThat(service.greet("Ann")).isEqualTo("Hello, Ann!");
    }
}

Fast, and where the bulk of your tests should live.

Slice tests

Start only the part of the context you need.

AnnotationLoads
@WebMvcTestcontrollers, JSON mapping, no services
@DataJpaTestJPA, repositories, in-memory database
@RestClientTestHTTP clients with a mock server
@JsonTestserialization only
@WebMvcTest(BookController.class)
class BookControllerTest {

    @Autowired RestTestClient client;
    @MockitoBean BookService service;             // replaces the bean in the context

    @Test
    void returnsBook() {
        given(service.byId(1)).willReturn(new Book(1, "Dune"));

        client.get().uri("/api/books/1")
              .exchange()
              .expectStatus().isOk()
              .expectBody().jsonPath("$.title").isEqualTo("Dune");
    }
}
New in Spring Boot 4

RestTestClient is the one fluent test client for both mock and running servers. @MockBean is gone — use @MockitoBean (and @MockitoSpyBean).

Repository tests

@DataJpaTest
class BookRepositoryTest {

    @Autowired BookRepository repo;

    @Test
    void findsByAuthor() {
        repo.save(new Book("Dune", "Herbert", 1965));

        assertThat(repo.findByAuthor("Herbert")).hasSize(1);
    }
}

Each test runs in a transaction that is rolled back afterwards.

Full integration tests

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class BookApiIT {

    @Autowired RestTestClient client;

    @Test
    void createsAndReadsBack() {
        client.post().uri("/api/books")
              .body(new BookRequest("Dune", "Herbert", 1965))
              .exchange()
              .expectStatus().isCreated();

        client.get().uri("/api/books")
              .exchange()
              .expectBody().jsonPath("$.length()").isEqualTo(1);
    }
}

Slow — a handful of these is enough.

Test configuration

src/test/resources/application.properties overrides settings during tests:

spring.jpa.hibernate.ddl-auto=create-drop
logging.level.org.springframework.web=DEBUG

Or use a profile: @ActiveProfiles("test").

A real database with Testcontainers

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class PostgresIT {

    @Container @ServiceConnection
    static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:17");
}

@ServiceConnection wires the container’s URL, user and password into the context — no property files.

What to test

  • services: business rules, plain unit tests
  • controllers: status codes, validation, JSON shape — @WebMvcTest
  • repositories: custom queries only — @DataJpaTest
  • end to end: one or two happy paths — @SpringBootTest
Warning

A test that starts the whole context to check a string comparison costs seconds every run. Push tests down the pyramid.

★ Exercises

  1. Unit-test a service with no Spring annotations.
  2. Write a @WebMvcTest with @MockitoBean for the service.
  3. Assert that an invalid POST body returns 400 and names the bad field.
  4. Write a @DataJpaTest for a derived query method.
  5. Add one @SpringBootTest covering create-then-read.