What is Spring Boot?
Spring Boot is a layer on top of the Spring Framework that removes the setup Spring once required. Instead of XML files, servlet configuration and a container to deploy into, you write a class with a main method, add a starter dependency, and get a running application with an embedded server.
Three ideas do the heavy lifting. Starters bundle the dependencies for a capability into one line. Auto-configuration inspects the classpath and configures beans from sensible defaults. The embedded server means the application is a normal Java process — there is nothing to install or deploy into.
The result is that Spring Boot has become the default way to build JVM services, from small internal APIs to large microservice fleets, while keeping the full power of the Spring ecosystem underneath.
The Spring ecosystem in one minute
Spring is not one library but a family that shares a programming model:
- Spring Framework — the core container, dependency injection and the web layer.
- Spring Data — repositories over SQL and NoSQL stores.
- Spring Security — authentication and authorisation.
- Spring Batch / Integration — bulk processing and messaging.
- Spring Cloud — configuration, discovery and resilience for distributed systems.
Because they are designed together, a project can adopt them one at a time without changing how it is written. That consistency is the real reason teams standardise on Spring.
Starters and dependency management
A starter is a curated set of dependencies that work together. You declare the capability, not the individual jars.
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
The Gradle equivalent is just as short:
dependencies {
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
implementation "org.springframework.boot:spring-boot-starter-validation"
runtimeOnly "org.postgresql:postgresql"
}
The parent POM or the dependency-management plugin fixes compatible versions for you. Override a version only when you have a reason, and let the platform manage the rest.
The application class and embedded server
Every Spring Boot application starts here. This one class is enough to launch a web server.
package com.example.blog;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BlogApplication {
public static void main(String[] args) {
SpringApplication.run(BlogApplication.class, args);
}
}
@SpringBootApplication combines three annotations: @Configuration, @EnableAutoConfiguration and @ComponentScan. Component scanning starts in the package of this class and looks downward, which is why the main class belongs at the root of your package structure.
Running ./mvnw spring-boot:run starts an embedded Tomcat on port 8080. You can swap in Jetty or Netty with a dependency change, and the application code does not care.
The annotated web layer
Spring MVC maps HTTP requests to methods with annotations. @RestController combines @Controller and @ResponseBody, so returned objects are serialised to JSON by Jackson.
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostService service;
public PostController(PostService service) {
this.service = service;
}
@GetMapping
public List<PostResponse> list(
@RequestParam(defaultValue = "0") int page) {
return service.findPage(page);
}
@GetMapping("/{id}")
public PostResponse get(@PathVariable Long id) {
return service.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public PostResponse create(@Valid @RequestBody CreatePostRequest request) {
return service.create(request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
service.delete(id);
}
}
@PathVariable reads from the URL, @RequestParam from the query string, and @RequestBody deserialises the payload. Keep controllers thin: they translate HTTP into a call on a service and translate the result back. Domain logic belongs in the service layer.
Dependency injection and the application context
The application context is the container that creates objects, called beans, and supplies their dependencies. You declare what a class needs; Spring decides how to build it.
@Service
public class PostService {
private final PostRepository repository;
private final Clock clock;
public PostService(PostRepository repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
@Transactional
public PostResponse create(CreatePostRequest request) {
var post = new Post(request.title(), request.body(), clock.instant());
return PostResponse.from(repository.save(post));
}
}
Stereotype annotations mark the classes to manage: @Component for generic beans, @Service for domain services, @Repository for data access and @RestController for web endpoints. For anything you cannot annotate — a third-party class, a configured client — declare it in a @Configuration class.
@Configuration
class ClockConfig {
@Bean
Clock clock() {
return Clock.systemUTC();
}
}
Prefer constructor injection everywhere. It makes dependencies explicit, allows final fields and lets you instantiate the class in a plain unit test without starting Spring.
Spring Data JPA and the database
Entities map Java objects to tables, and repositories give you data access without an implementation.
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String title;
@Column(nullable = false)
private String body;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Author author;
protected Post() {
}
public Post(String title, String body, Author author) {
this.title = title;
this.body = body;
this.author = author;
}
// getters and domain methods
}
public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByPublishedTrueOrderByCreatedAtDesc();
@Query("select p from Post p join fetch p.author where p.id = :id")
Optional<Post> findWithAuthor(Long id);
}
Spring Data derives a query from the method name, so findByPublishedTrueOrderByCreatedAtDesc needs no body. Use @Query when the derived name would be unreadable, and join fetch to avoid the lazy-loading N+1 problem. Keep ddl-auto at validate in production and manage the schema with Flyway or Liquibase.
Configuration with application.yml and profiles
Configuration lives in src/main/resources and can be overridden by environment variables, which is what makes the same jar run everywhere.
# src/main/resources/application.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/blog
username: blog
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
server:
port: 8080
Profiles let one build behave differently per environment.
# src/main/resources/application-prod.yml
spring:
datasource:
url: ${DATABASE_URL}
logging:
level:
root: warn
Activate a profile with --spring.profiles.active=prod or the SPRING_PROFILES_ACTIVE environment variable. @ConfigurationProperties binds a group of settings to a typed class, which is safer than scattering @Value annotations through the codebase.
Validation
Jakarta Bean Validation constraints on a request object are enforced when the controller parameter is annotated with @Valid.
public record CreatePostRequest(
@NotBlank @Size(max = 120) String title,
@NotBlank String body
) {
}
A @RestControllerAdvice centralises the error response so every endpoint returns the same shape.
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> onValidation(MethodArgumentNotValidException ex) {
return ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
FieldError::getDefaultMessage,
(first, second) -> first));
}
}
Validate at the boundary and let the domain trust its inputs. Combined with a consistent exception handler, this removes repetitive if (input == null) checks from every service.
Spring Security in brief
Spring Security is a filter chain in front of your application. In modern Spring Boot you configure it with a bean rather than XML.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));
return http.build();
}
}
That single bean requires a valid JWT on every endpoint except the health check. Method-level rules with @PreAuthorize add fine-grained authorisation where it is needed. Security is deep — plan to read the documentation carefully before exposing anything to the internet.
Actuator: health and metrics
Adding the Actuator starter exposes operational endpoints for free.
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always
curl localhost:8080/actuator/health
/actuator/health integrates with liveness and readiness probes, and /actuator/metrics reports JVM, HTTP and datasource statistics. Expose only what you need, and secure the rest — these endpoints reveal a lot about a running system.
Testing
@SpringBootTest starts the application context and is the base for integration tests. MockMvc exercises the web layer without opening a real port.
@SpringBootTest
@AutoConfigureMockMvc
class PostControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void listsPosts() throws Exception {
mockMvc.perform(get("/api/posts"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Hello"));
}
}
For faster feedback, slice the context to just the layer under test and mock the collaborators.
@WebMvcTest(PostController.class)
class PostControllerSliceTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private PostService service;
@Test
void returnsNotFound() throws Exception {
given(service.findById(1L)).willThrow(new PostNotFoundException(1L));
mockMvc.perform(get("/api/posts/1"))
.andExpect(status().isNotFound());
}
}
Use slices for controllers and repositories, and reserve the full context for a few end-to-end tests. Testcontainers gives you a real database in tests without mocking persistence.
Building and deploying
The build produces an executable fat jar that contains your classes, dependencies and the embedded server.
./mvnw clean package
java -jar target/blog-0.0.1-SNAPSHOT.jar
For containers, Buildpacks create an optimised image without a Dockerfile.
./mvnw spring-boot:build-image
Configuration comes from the environment, so the same artifact moves from staging to production unchanged. Add a Dockerfile only when you need control Buildpacks do not offer, and prefer a multi-stage build to keep the image small.
Best practices
- Package by feature so each domain owns its controller, service and repository.
- Use constructor injection and keep fields
final. - Return DTOs from controllers instead of JPA entities.
- Validate request objects with
@Validand handle errors in one@RestControllerAdvice. - Keep
ddl-autoatvalidateand manage schema changes with Flyway or Liquibase. - Externalise configuration and use profiles instead of rebuilding for each environment.
- Add Actuator, then expose and secure only the endpoints you actually need.
Common mistakes
- Field injection with
@Autowired, which hides dependencies and complicates testing. - Serialising entities directly and leaking lazy-loading or internal columns.
- Letting controllers grow business logic instead of delegating to services.
- Using
ddl-auto: updatein production and drifting from the real schema. - Starting the whole context in every unit test, making the suite slow.
- Exposing every Actuator endpoint to the public internet.
- Adding starters you do not need and paying for them at startup.
Where to go next
Spring Boot teaches dependency injection, auto-configuration and layered design — ideas that appear far beyond Java. If you like the module-and-injection model, NestJS applies it in TypeScript, while Express shows the minimal alternative. The annotated web layer is ultimately a REST API, and most applications persist to a relational store, so PostgreSQL is the natural next step.