Overview
This course is organized as a categorized reference — one lesson per dependency, each with its own use case, coordinates, and minimal example — because that is the fastest way to learn what a specific starter does in isolation. A real Spring Boot `pom.xml` never has just one starter in it, though: a production API is a web starter plus whatever else that particular application needs on top, and those starters have to agree with each other, not just work individually.
This project takes four starters this course covers in separate lessons — `spring-boot-starter-web`, `spring-boot-starter-security`, `spring-boot-starter-validation`, and `spring-boot-starter-actuator` — and combines them into one small, genuinely production-shaped API: a `ProductController` whose write endpoint is validated, whose read and write endpoints both require authentication, and whose health and info endpoints are exposed for a monitoring system to poll. Every step below calls out exactly which starter is responsible for the behavior you are about to see, since that mapping — "this starter is why this line of code works" — is the whole point of the course this project sits inside.
- A `pom.xml` combining the web, security, validation, and actuator starters under `spring-boot-starter-parent`'s BOM.
- A `Product` model and an in-memory `ProductStore`, deliberately dependency-free of any database starter.
- A `ProductRequest` DTO using `@NotBlank`/`@Positive`, validated by `@Valid` on the controller.
- A `ProductController` exposing `GET`/`POST /api/products`, protected end to end by Spring Security.
- A `SecurityFilterChain` requiring HTTP Basic auth on the API while leaving `/actuator/health` public.
- Actuator `health`, `info`, and `metrics` endpoints, secured by the same filter chain as the API itself.
Prerequisites
- This course's Understanding Spring Boot Starters and Web & REST API Dependencies lessons.
- This course's Security Dependencies and Validation Dependencies lessons — this project assumes you have already seen each starter's single-dependency example.
- Basic Spring Boot fundamentals — `@RestController`, `@Configuration`, and constructor-based dependency injection.
- HTTP basics — `Authorization: Basic`, and status codes `200`, `201`, `400`, and `401`.
- A Spring Boot 3.x project generated from Spring Initializr, or the `pom.xml` built from scratch in Step 1.
Project Structure
Generating a project with `com.programinds.productapi` as the group/artifact produces this layout: `src/main/java/com/programinds/productapi/ProductApiApplication.java` holds `main()`; `model/` holds `Product`; `dto/` holds `ProductRequest`; `service/` holds `ProductStore`; `security/` holds `SecurityConfig`; `controller/` holds `ProductController`; and `src/main/resources/application.properties` holds the actuator exposure settings from Step 5.
Notice what is missing: there is no `repository/` package and no database starter in `pom.xml` at all. That is intentional — this project is about the interaction between web, security, validation, and actuator, and adding Spring Data JPA on top would make it a persistence project as much as a dependency-combination one. `ProductStore` uses an in-memory `ConcurrentHashMap` instead, which is exactly the kind of substitution the Extend section revisits.
Step 1: Add the Four Starters to pom.xml
Every dependency below omits its own `<version>` tag on purpose — `spring-boot-starter-parent` is itself a BOM (Bill of Materials), and any starter declared without an explicit version inherits one from it, pre-tested against every other starter in the same BOM. `spring-boot-starter-web` pulls in Spring MVC, an embedded Tomcat, and Jackson; `spring-boot-starter-security` pulls in Spring Security's core and its servlet filter integration, and — until Step 4's `SecurityFilterChain` bean says otherwise — a default configuration that locks every endpoint behind a generated login password; `spring-boot-starter-validation` pulls in Hibernate Validator, the actual engine `@Valid` delegates to; and `spring-boot-starter-actuator` pulls in Micrometer and the `/actuator/*` endpoints built in Step 5.
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion>
<!-- spring-boot-starter-parent is itself a BOM: every dependency below that omits a <version> gets its version from here, pre-tested to work together. --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent>
<groupId>com.programinds</groupId> <artifactId>productapi</artifactId> <version>0.0.1-SNAPSHOT</version> <packaging>jar</packaging>
<properties> <java.version>17</java.version> </properties>
<dependencies> <!-- Spring MVC + embedded Tomcat + Jackson: everything needed to expose @RestController endpoints over HTTP and serialize their return values to JSON. --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
<!-- Spring Security core + servlet filter integration. Adding this alone, with no SecurityFilterChain bean, already locks every endpoint behind a generated one-time password printed to the console at startup. --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
<!-- Hibernate Validator, the Jakarta Bean Validation reference implementation. Without this starter, @Valid and @NotBlank in Step 2 would not even compile — the jakarta.validation-api classes themselves arrive transitively through here. --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
<!-- Micrometer + the /actuator/* endpoints (health, info, metrics, ...) that report on the running application itself, built on in Step 5. --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!-- spring-security-test adds @WithMockUser and MockMvc security request matchers, used to test Step 4's protected endpoints without sending real credentials. --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> </dependencies>
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build></project>Step 2: Build the Product Model and Validated Request DTO
`Product` is a plain class, not a JPA `@Entity` — this project's dependency set has no database starter in it, so `ProductStore` (below) holds every product in memory instead of a table. `ProductRequest` is the DTO the controller actually accepts: `@NotBlank` and `@Positive` are Jakarta Bean Validation annotations, and they only do anything at all because `spring-boot-starter-validation` from Step 1 put Hibernate Validator on the classpath — remove that one starter and these two annotations become inert text that nothing ever reads.
package com.programinds.productapi.model;
// A plain POJO, not a JPA @Entity. This project's pom.xml has no persistence starter// in it on purpose — products live in the in-memory ProductStore below, not a table.public class Product {
private final Long id; private String name; private double price;
public Product(Long id, String name, double price) { this.id = id; this.name = name; this.price = price; }
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public double getPrice() { return price; }
public void setPrice(double price) { this.price = price; }}package com.programinds.productapi.dto;
import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.Positive;
// Both annotations below are supplied by spring-boot-starter-validation (Step 1) — without// that starter on the classpath, this file would fail to compile at the import lines.public class ProductRequest {
@NotBlank(message = "Name must not be blank") private String name;
@Positive(message = "Price must be greater than zero") private double price;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public double getPrice() { return price; }
public void setPrice(double price) { this.price = price; }}package com.programinds.productapi.service;
import com.programinds.productapi.model.Product;import org.springframework.stereotype.Service;
import java.util.Collection;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.atomic.AtomicLong;
// Stands in for a real database in this project — the point here is the web/security/// validation/actuator wiring, not persistence (see this course's Data & Persistence// Dependencies lesson, or the springboot course's JPA-backed projects, for that).@Servicepublic class ProductStore {
private final Map<Long, Product> products = new ConcurrentHashMap<>(); private final AtomicLong nextId = new AtomicLong(1); // AtomicLong: a plain "long id++" is not safe under concurrent requests
public Collection<Product> findAll() { return products.values(); }
public Product save(String name, double price) { long id = nextId.getAndIncrement(); Product product = new Product(id, name, price); products.put(id, product); return product; }}Step 3: Build the REST Controller
`ProductController` looks almost identical to a controller from a plain `spring-boot-starter-web`-only project — that is deliberate. Security and validation are cross-cutting concerns handled by a filter chain (Step 4) and an annotation Spring evaluates before this method body runs (`@Valid`), not by extra logic written inside the controller itself. That separation is exactly why these four starters can be combined without `ProductController` growing more complicated as each one is added.
package com.programinds.productapi.controller;
import com.programinds.productapi.dto.ProductRequest;import com.programinds.productapi.model.Product;import com.programinds.productapi.service.ProductStore;import jakarta.validation.Valid;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;
import java.util.Collection;
@RestController@RequestMapping("/api/products") // Every method here requires authentication once Step 4's SecurityFilterChain is in placepublic class ProductController {
private final ProductStore productStore;
public ProductController(ProductStore productStore) { this.productStore = productStore; }
@GetMapping public Collection<Product> getAll() { return productStore.findAll(); }
@PostMapping public ResponseEntity<Product> create(@Valid @RequestBody ProductRequest request) { // @Valid triggers ProductRequest's @NotBlank/@Positive checks; an invalid body // never reaches this line — Spring responds with 400 Bad Request automatically. Product saved = productStore.save(request.getName(), request.getPrice()); return ResponseEntity.status(HttpStatus.CREATED).body(saved); }}Step 4: Configure the SecurityFilterChain
This project has no database starter, so there is nowhere to store real user accounts — `InMemoryUserDetailsManager` is a reasonable stand-in for demonstrating how the security starter's wiring works, not something a real deployment would ship with. The important line is the ordering inside `authorizeHttpRequests`: `/actuator/health` is matched and permitted before the broader `/actuator/**` and `/api/**` rules, because Spring Security evaluates matchers top to bottom and a more specific rule has to come first or the catch-all below it would win instead.
package com.programinds.productapi.security;
import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.Customizer;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.core.userdetails.User;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.provisioning.InMemoryUserDetailsManager;import org.springframework.security.web.SecurityFilterChain;
// No database starter in this project's pom.xml means no table to load users from —// InMemoryUserDetailsManager demonstrates the security starter's wiring without one.// A real deployment would swap this for a UserDetailsService backed by a database,// the same pattern the springboot course's Authenticated Notes App project builds.@Configurationpublic class SecurityConfig {
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); // One-way hash; login compares hashes, never decrypts anything }
@Bean public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { var admin = User.withUsername("admin") .password(passwordEncoder.encode("changeit")) .roles("USER") .build(); return new InMemoryUserDetailsManager(admin); }
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) // Stateless API tested with curl/Postman, not a session-cookie browser form .authorizeHttpRequests(auth -> auth // Listed first and deliberately public: load balancers and container // orchestrators poll /actuator/health before the app is ever authenticated. .requestMatchers("/actuator/health").permitAll() // Everything else under /actuator/** (info, metrics, env, ...) can reveal // internal details, so it stays behind authentication like the API itself. .requestMatchers("/actuator/**").authenticated() .requestMatchers("/api/**").authenticated() .anyRequest().authenticated() ) .httpBasic(Customizer.withDefaults()); // Credentials sent via the Authorization header on every protected request
return http.build(); }}Step 5: Expose and Secure the Actuator Endpoints
By default, `spring-boot-starter-actuator` only exposes `/actuator/health` over HTTP — every other endpoint, including `info` and `metrics`, is disabled until `application.properties` explicitly opts it in. That default matters for this project specifically: it is why Step 4 has anything beyond health to secure at all, and why opening more endpoints and locking them down are treated as one deliberate decision rather than two separate ones.
spring.application.name=productapi
# By default only /actuator/health is exposed over HTTP. This project explicitly opens# info and metrics too, so SecurityConfig's authorizeHttpRequests rules have something# beyond health to actually secure.management.endpoints.web.exposure.include=health,info,metricsmanagement.endpoint.health.show-details=when-authorized
# Surfaces the info.* properties below at GET /actuator/info once authenticated.management.info.env.enabled=trueinfo.app.name=Product APIinfo.app.description=Practice build combining web, security, validation, and actuator starters`when-authorized` means an unauthenticated caller polling `/actuator/health` still gets a plain `{"status":"UP"}`, while a signed-in caller sees the breakdown per health indicator (disk space, ping, etc.). Setting this to `always` would hand that breakdown to anyone on the internet who can reach the endpoint at all, which is more detail than a public health check should ever leak.
Complete Code
Here is the full project across its files, plus the application entry point. Run it with `./mvnw spring-boot:run` — the in-memory `admin`/`changeit` account and product store are created fresh every time the app starts.
package com.programinds.productapi;
import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication // Enables auto-configuration, component scanning, and the embedded Tomcat serverpublic class ProductApiApplication { public static void main(String[] args) { SpringApplication.run(ProductApiApplication.class, args); }}package com.programinds.productapi.model;
public class Product {
private final Long id; private String name; private double price;
public Product(Long id, String name, double price) { this.id = id; this.name = name; this.price = price; }
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public double getPrice() { return price; }
public void setPrice(double price) { this.price = price; }}package com.programinds.productapi.dto;
import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.Positive;
public class ProductRequest {
@NotBlank(message = "Name must not be blank") private String name;
@Positive(message = "Price must be greater than zero") private double price;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public double getPrice() { return price; }
public void setPrice(double price) { this.price = price; }}package com.programinds.productapi.service;
import com.programinds.productapi.model.Product;import org.springframework.stereotype.Service;
import java.util.Collection;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.atomic.AtomicLong;
@Servicepublic class ProductStore {
private final Map<Long, Product> products = new ConcurrentHashMap<>(); private final AtomicLong nextId = new AtomicLong(1);
public Collection<Product> findAll() { return products.values(); }
public Product save(String name, double price) { long id = nextId.getAndIncrement(); Product product = new Product(id, name, price); products.put(id, product); return product; }}package com.programinds.productapi.security;
import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.Customizer;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.core.userdetails.User;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.provisioning.InMemoryUserDetailsManager;import org.springframework.security.web.SecurityFilterChain;
@Configurationpublic class SecurityConfig {
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
@Bean public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { var admin = User.withUsername("admin") .password(passwordEncoder.encode("changeit")) .roles("USER") .build(); return new InMemoryUserDetailsManager(admin); }
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health").permitAll() .requestMatchers("/actuator/**").authenticated() .requestMatchers("/api/**").authenticated() .anyRequest().authenticated() ) .httpBasic(Customizer.withDefaults());
return http.build(); }}package com.programinds.productapi.controller;
import com.programinds.productapi.dto.ProductRequest;import com.programinds.productapi.model.Product;import com.programinds.productapi.service.ProductStore;import jakarta.validation.Valid;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;
import java.util.Collection;
@RestController@RequestMapping("/api/products")public class ProductController {
private final ProductStore productStore;
public ProductController(ProductStore productStore) { this.productStore = productStore; }
@GetMapping public Collection<Product> getAll() { return productStore.findAll(); }
@PostMapping public ResponseEntity<Product> create(@Valid @RequestBody ProductRequest request) { Product saved = productStore.save(request.getName(), request.getPrice()); return ResponseEntity.status(HttpStatus.CREATED).body(saved); }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Swap `InMemoryUserDetailsManager` for a real `UserDetailsService` backed by `spring-boot-starter-data-jpa`, following the springboot course's Authenticated Notes App project.
- Add role-based endpoint security so only an `ADMIN` role can reach `/actuator/metrics`, while a plain `USER` can still reach `/actuator/health` and `/actuator/info`.
- Add `micrometer-registry-prometheus` from this course's Actuator & Monitoring Dependencies lesson so `/actuator/prometheus` exposes metrics in a scrapeable format.
- Add `springdoc-openapi-starter-webmvc-ui` to generate interactive API docs directly from `ProductRequest`'s validation annotations.
- Replace HTTP Basic with stateless JWT authentication, following this course's OAuth2 & JWT Dependencies lesson, so clients stop resending credentials on every request.
Summary
You combined four starters this course covers individually into one API where each one's job stayed exactly as narrow as its lesson described: `spring-boot-starter-web` routed and serialized, `spring-boot-starter-validation` rejected a bad request before your own code ran, `spring-boot-starter-security` decided who could reach anything at all, and `spring-boot-starter-actuator` reported on the running application itself. Nothing in `ProductController` had to know about security or validation directly — that separation, each starter handling its own cross-cutting concern without the others' code growing more complicated, is what makes a real Spring Boot `pom.xml` with a dozen starters in it manageable instead of tangled.