Spring Boot
Complete Reference
A comprehensive guide to Spring Boot — from IoC container fundamentals through the presentation, business, and data layers to security, scheduling, and observability.
What is Spring Boot?
Spring Boot is an opinionated, convention-over-configuration framework that sits on top of the Spring Framework. It eliminates the XML boilerplate and manual wiring that traditional Spring required, replacing it with auto-configuration, embedded servers, and starter dependencies — letting you build production-ready applications with minimal setup.
The three core promises: auto-configure everything sensible, embed Tomcat/Jetty directly, and provide production-ready features out of the box (health checks, metrics, externalized configuration).
spring-boot-starter-data-jpa to your classpath, Spring Boot
automatically configures a DataSource, EntityManagerFactory, and transaction manager —
no XML, no @Bean definitions required unless you want to override the defaults.
Single entry point
One annotation bootstraps your entire application context.
Curated dependencies
Add one starter; get a complete, tested dependency set.
Fat JAR deployment
Ship a single executable JAR — no Tomcat installation needed.
Production ready
Health, metrics, and info endpoints available by default.
Architecture overview
A Spring Boot application is organized into three horizontal tiers — Presentation,
Business Logic, and Data — all wired together by the
IoC (Inversion of Control) container, known as the
ApplicationContext. The container manages beans, resolves dependencies
through injection, and controls the lifecycle of every component.
Auto-Configuration & @SpringBootApplication
The entry point of any Spring Boot app is a single class annotated with
@SpringBootApplication. Under the hood this is three annotations in one:
@Configuration, @EnableAutoConfiguration, and
@ComponentScan.
@SpringBootApplication // = @Configuration + @EnableAutoConfiguration + @ComponentScan
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
// What happens on run():
// 1. Creates ApplicationContext
// 2. Scans @Component, @Service, @Repository, @Controller in the package tree
// 3. Auto-configures beans based on classpath (DataSource, JPA, Tomcat, ...)
// 4. Runs ApplicationRunner / CommandLineRunner beans
// 5. Starts the embedded web server<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<dependencies>
<!-- Web MVC + embedded Tomcat -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA + Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Bean validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Actuator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>// Override auto-configured beans with your own @Bean definitions
@Configuration
public class AppConfig {
// Override default ObjectMapper (Jackson JSON serializer)
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper()
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
.registerModule(new JavaTimeModule());
}
// Declare a shared RestTemplate bean
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(10))
.build();
}
// ConditionalOnMissingBean — only creates this if you haven't defined your own
@Bean
@ConditionalOnMissingBean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}Controller layer
Controllers are the entry points for HTTP requests. Annotated with
@RestController (which combines @Controller and
@ResponseBody), they map URL paths to Java methods and handle
serialization automatically via Jackson.
Core annotations
| Annotation | Purpose | HTTP method |
|---|---|---|
| @GetMapping | Read a resource or list | GET |
| @PostMapping | Create a new resource | POST |
| @PutMapping | Full replacement of a resource | PUT |
| @PatchMapping | Partial update of a resource | PATCH |
| @DeleteMapping | Delete a resource | DELETE |
| @RequestBody | Deserialize request JSON into a Java object | — |
| @PathVariable | Extract value from URL segment /users/{id} | — |
| @RequestParam | Extract query parameter ?page=0&size=10 | — |
| @ResponseStatus | Set the default HTTP status of a method | — |
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
// Constructor injection — preferred over @Autowired
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public List<UserDto> listUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return userService.findAll(PageRequest.of(page, size));
}
@GetMapping("/{id}")
public UserDto getUser(@PathVariable Long id) {
return userService.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserDto createUser(@Valid @RequestBody CreateUserRequest req) {
return userService.create(req);
}
@PutMapping("/{id}")
public UserDto updateUser(@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest req) {
return userService.update(id, req);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
userService.delete(id);
}
}// ResponseEntity gives you fine-grained control over status, headers, body
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping("/{id}")
public ResponseEntity<ProductDto> getProduct(@PathVariable Long id) {
return userService.findOptional(id)
.map(ResponseEntity::ok) // 200 OK
.orElse(ResponseEntity.notFound().build()); // 404
}
@PostMapping
public ResponseEntity<ProductDto> createProduct(@RequestBody CreateProductRequest req) {
ProductDto created = productService.create(req);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity
.created(location) // 201 + Location header
.body(created);
}
@GetMapping("/export")
public ResponseEntity<byte[]> exportCsv() {
byte[] csv = reportService.generateCsv();
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=products.csv")
.contentType(MediaType.TEXT_PLAIN)
.body(csv);
}
}// DTOs (Data Transfer Objects) decouple API contracts from domain entities
// Immutable request DTO using Java 17 record
public record CreateUserRequest(
@NotBlank @Size(min=2, max=50) String name,
@NotBlank @Email String email,
@NotBlank @Size(min=8) String password
) {}
// Response DTO — never expose the raw @Entity
public record UserDto(
Long id,
String name,
String email,
LocalDateTime createdAt
) {
// Static factory method from entity
public static UserDto from(User user) {
return new UserDto(user.getId(), user.getName(),
user.getEmail(), user.getCreatedAt());
}
}
// Update DTO — all fields optional for PATCH
public record UpdateUserRequest(
@Size(min=2, max=50) String name, // null = unchanged
@Email String email
) {}Service layer
The service layer holds all business logic, orchestrates calls to repositories, and enforces transactional boundaries. It is injected into controllers via the IoC container and should never be called directly by the data layer.
@Service // marks as a Spring-managed component; also communicates intent
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final ApplicationEventPublisher eventPublisher;
// Constructor injection — all dependencies are final (immutable)
public UserService(UserRepository userRepository,
PasswordEncoder passwordEncoder,
ApplicationEventPublisher eventPublisher) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.eventPublisher = eventPublisher;
}
public List<UserDto> findAll(Pageable pageable) {
return userRepository.findAll(pageable)
.stream()
.map(UserDto::from)
.toList();
}
public UserDto findById(Long id) {
return userRepository.findById(id)
.map(UserDto::from)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
}
@Transactional
public UserDto create(CreateUserRequest req) {
if (userRepository.existsByEmail(req.email())) {
throw new EmailAlreadyExistsException(req.email());
}
User user = new User();
user.setName(req.name());
user.setEmail(req.email());
user.setPassword(passwordEncoder.encode(req.password()));
User saved = userRepository.save(user);
eventPublisher.publishEvent(new UserCreatedEvent(saved));
return UserDto.from(saved);
}
@Transactional
public void delete(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
userRepository.delete(user);
}
}// @Transactional wraps the entire method in a single database transaction.
// If an unchecked exception escapes, the transaction is automatically rolled back.
@Service
public class OrderService {
@Transactional // default: propagation=REQUIRED, rollbackOn=RuntimeException
public Order placeOrder(PlaceOrderRequest req) {
// All of these run in ONE transaction:
inventoryService.reserve(req.items()); // deduct stock
Order order = orderRepository.save(...); // persist order
paymentService.charge(req.payment()); // record payment
// If ANY step throws RuntimeException → entire transaction rolls back
return order;
}
@Transactional(readOnly = true) // hint to JPA: skip dirty checking → faster
public List<Order> getOrdersForUser(Long userId) {
return orderRepository.findByUserId(userId);
}
@Transactional(propagation = Propagation.REQUIRES_NEW) // always a fresh transaction
public void auditLog(String action) {
auditRepository.save(new AuditEntry(action, LocalDateTime.now()));
}
@Transactional(rollbackFor = CheckedPaymentException.class) // also rollback on checked
public void processPayment(Payment p) throws CheckedPaymentException {
// ...
}
}Repository layer
Spring Data JPA automatically implements repositories — you declare an interface
extending JpaRepository<Entity, ID> and Spring generates all
the SQL at startup. No EntityManager code required for standard operations.
// JpaRepository provides: save, findById, findAll, delete, count, existsById...
@Repository // optional — Spring Data detects Repository interfaces automatically
public interface UserRepository extends JpaRepository<User, Long> {
// Derived query — Spring generates SQL from the method name
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
List<User> findByActiveTrue();
List<User> findByCreatedAtAfter(LocalDateTime since);
// Sorting and paging built-in
Page<User> findByActive(boolean active, Pageable pageable);
// Derived delete
void deleteByEmail(String email);
}public interface UserRepository extends JpaRepository<User, Long> {
// JPQL query (entity-based, database-agnostic)
@Query("SELECT u FROM User u WHERE u.email = :email AND u.active = true")
Optional<User> findActiveByEmail(@Param("email") String email);
// Native SQL query (database-specific)
@Query(value = "SELECT * FROM users WHERE LOWER(name) LIKE LOWER(:pattern)",
nativeQuery = true)
List<User> searchByName(@Param("pattern") String pattern);
// Projection — return only selected fields
@Query("SELECT u.id AS id, u.name AS name, u.email AS email FROM User u")
List<UserSummary> findAllSummaries();
// Modifying query — must pair with @Transactional
@Modifying
@Transactional
@Query("UPDATE User u SET u.active = false WHERE u.lastLoginAt < :cutoff")
int deactivateInactiveUsers(@Param("cutoff") LocalDateTime cutoff);
}
// Projection interface
interface UserSummary {
Long getId();
String getName();
String getEmail();
}// Specifications enable dynamic queries built at runtime
public interface UserRepository extends JpaRepository<User, Long>,
JpaSpecificationExecutor<User> {}
// Specification builder
public class UserSpecs {
public static Specification<User> hasEmail(String email) {
return (root, query, cb) -> cb.equal(root.get("email"), email);
}
public static Specification<User> isActive() {
return (root, query, cb) -> cb.isTrue(root.get("active"));
}
public static Specification<User> createdAfter(LocalDateTime date) {
return (root, query, cb) -> cb.greaterThan(root.get("createdAt"), date);
}
}
// Usage — compose specs dynamically
@Service
public class UserService {
public List<User> search(String email, boolean onlyActive) {
Specification<User> spec = Specification.where(null);
if (email != null) spec = spec.and(UserSpecs.hasEmail(email));
if (onlyActive) spec = spec.and(UserSpecs.isActive());
return userRepository.findAll(spec);
}
}Entity & Data Model
JPA entities are plain Java classes annotated with @Entity. Hibernate
(the default JPA provider in Spring Boot) maps them to database tables, manages the
object lifecycle, and generates SQL automatically.
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "full_name", nullable = false, length = 100)
private String name;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private boolean active = true;
@CreationTimestamp // Hibernate sets on INSERT
@Column(updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp // Hibernate sets on UPDATE
private LocalDateTime updatedAt;
// Getters and setters ...
}@Entity @Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Many orders belong to one user
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
// One order has many line items — cascade: persist/delete propagates
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@Enumerated(EnumType.STRING)
private OrderStatus status = OrderStatus.PENDING;
// Helper method to maintain bidirectional consistency
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
}
@Entity @Table(name = "order_items")
public class OrderItem {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
@Column(nullable = false)
private String productName;
private int quantity;
private BigDecimal unitPrice;
}// Enable JPA Auditing once in your @Configuration
@SpringBootApplication
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class MyApplication { ... }
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(ctx -> ctx.getAuthentication())
.map(auth -> auth.getName());
}
// Now annotate your entity to auto-capture audit fields
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Product {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreatedDate @Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
@CreatedBy @Column(updatable = false, length = 100)
private String createdBy;
@LastModifiedBy @Column(length = 100)
private String updatedBy;
}Request lifecycle
Every HTTP request travels through a strict pipeline before reaching your controller. Understanding this pipeline is essential for debugging, adding cross-cutting behaviour, and knowing where each concern lives.
Exception handling
Spring Boot provides two complementary mechanisms. A local
@ExceptionHandler inside a controller handles exceptions from that
controller only. A @RestControllerAdvice class provides a global
fallback for every controller in the application.
Local handlers take priority over global ones.
Local — one controller
- Method inside @RestController
- Highest priority
- For controller-specific behavior
Global — all controllers
- Separate class, application-wide
- Fallback after local handlers
- Centralises the error JSON format
public record ErrorResponse(
int status,
String message,
String path,
LocalDateTime timestamp
) {
public static ErrorResponse of(int status, String message, String path) {
return new ErrorResponse(status, message, path, LocalDateTime.now());
}
}
// Result JSON: { "status":404, "message":"User not found: 42",
// "path":"/api/users/42", "timestamp":"2024-03-15T10:30:00" }@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex,
HttpServletRequest req) {
return ErrorResponse.of(404, ex.getMessage(), req.getRequestURI());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(MethodArgumentNotValidException ex,
HttpServletRequest req) {
String msg = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining(", "));
return ErrorResponse.of(400, msg, req.getRequestURI());
}
@ExceptionHandler(DataIntegrityViolationException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ErrorResponse handleConflict(DataIntegrityViolationException ex,
HttpServletRequest req) {
return ErrorResponse.of(409, "Data conflict: " + ex.getRootCause().getMessage(),
req.getRequestURI());
}
// Always add this last — prevents raw stack traces reaching the client
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneric(Exception ex, HttpServletRequest req) {
log.error("Unhandled exception at {}", req.getRequestURI(), ex);
return ErrorResponse.of(500, "An unexpected error occurred",
req.getRequestURI());
}
}// Typed exception — carries resource info for clear messages
public class ResourceNotFoundException extends RuntimeException {
private final String resourceType;
private final Object resourceId;
public ResourceNotFoundException(String type, Object id) {
super(type + " not found with id: " + id);
this.resourceType = type;
this.resourceId = id;
}
// getters ...
}
// Throw from service layer — caught by GlobalExceptionHandler
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
}Validation with Bean Validation
Add spring-boot-starter-validation to your classpath and Spring Boot
wires Jakarta Bean Validation automatically. Annotate request DTO fields with
constraint annotations, then add @Valid to the controller parameter.
public record RegisterRequest(
@NotBlank(message = "Name is required")
@Size(min = 2, max = 80, message = "Name must be 2–80 characters")
String name,
@NotBlank @Email(message = "Must be a valid email address")
String email,
@NotBlank
@Pattern(regexp = "^(?=.*[A-Z])(?=.*\\d).{8,}$",
message = "Password needs 8+ chars, one uppercase and one digit")
String password,
@NotNull @Min(18) @Max(120)
Integer age,
@NotNull @Future(message = "Appointment must be in the future")
LocalDate appointmentDate
) {}
// Controller: add @Valid to trigger validation
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public UserDto register(@Valid @RequestBody RegisterRequest req) {
return userService.register(req);
// Fails → MethodArgumentNotValidException → GlobalExceptionHandler → 400
}// 1. Define the annotation
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "Email is already registered";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// 2. Implement the ConstraintValidator
@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository userRepository;
public UniqueEmailValidator(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public boolean isValid(String email, ConstraintValidatorContext ctx) {
return email != null && !userRepository.existsByEmail(email);
}
}
// 3. Use it on a DTO field
public record RegisterRequest(
@UniqueEmail
@Email
String email,
// ...
) {}Spring Security
Spring Security plugs in as a filter chain that runs before the
DispatcherServlet. Every request passes through
SecurityFilterChain — if authentication fails, your controller is
never reached.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // disable for stateless APIs
.sessionManagement(s ->
s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // public
.requestMatchers("/api/admin/**").hasRole("ADMIN") // admin only
.requestMatchers(HttpMethod.GET, "/api/products").permitAll()
.anyRequest().authenticated() // everything else
)
.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
@Bean
public AuthenticationManager authManager(AuthenticationConfiguration cfg)
throws Exception {
return cfg.getAuthenticationManager();
}
}@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
final String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
String token = header.substring(7);
String subject = jwtService.extractSubject(token);
if (subject != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails user = userDetailsService.loadUserByUsername(subject);
if (jwtService.isTokenValid(token, user)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource()
.buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
chain.doFilter(request, response);
}
}// Enable method-level security in @Configuration
@EnableMethodSecurity
public class SecurityConfig { ... }
// Now protect individual service methods
@Service
public class DocumentService {
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public Document getDocument(Long userId, Long docId) {
return documentRepository.findByUserIdAndId(userId, docId)
.orElseThrow(() -> new ResourceNotFoundException("Document", docId));
}
@PreAuthorize("hasRole('ADMIN')")
public void deleteDocument(Long docId) {
documentRepository.deleteById(docId);
}
@PostAuthorize("returnObject.owner == authentication.name")
public Document findSecure(Long docId) {
return documentRepository.findById(docId)
.orElseThrow();
}
}Externalized configuration
Spring Boot reads configuration from application.properties or
application.yml. You can inject individual values with
@Value or bind whole prefixes to a typed class using
@ConfigurationProperties.
spring:
application:
name: my-service
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USER} # injected from environment variable
password: ${DB_PASS}
hikari:
maximum-pool-size: 10
jpa:
hibernate:
ddl-auto: validate # never use create/create-drop in production
show-sql: false
properties:
hibernate.format_sql: true
security:
jwt:
secret: ${JWT_SECRET}
expiry-ms: 3600000 # 1 hour
server:
port: 8080
error:
include-message: always # include error messages in responses
management:
endpoints:
web:
exposure:
include: health,info,metrics// Bind the "security.jwt.*" prefix to a typed record
@ConfigurationProperties(prefix = "security.jwt")
public record JwtProperties(
String secret,
long expiryMs
) {}
// Register it in your main class or a @Configuration class
@SpringBootApplication
@ConfigurationPropertiesScan // auto-detects all @ConfigurationProperties
public class MyApplication { ... }
// Inject and use it
@Service
public class JwtService {
private final JwtProperties jwtProps;
public JwtService(JwtProperties jwtProps) {
this.jwtProps = jwtProps;
}
public String generateToken(UserDetails user) {
return Jwts.builder()
.subject(user.getUsername())
.expiration(new Date(System.currentTimeMillis() + jwtProps.expiryMs()))
.signWith(Keys.hmacShaKeyFor(jwtProps.secret().getBytes()))
.compact();
}
}# application.yml — default (shared) config
spring:
jpa:
show-sql: false
---
# application-dev.yml — activated with: --spring.profiles.active=dev
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:h2:mem:testdb # in-memory DB for local dev
jpa:
show-sql: true
hibernate:
ddl-auto: create-drop
---
# application-prod.yml
spring:
config:
activate:
on-profile: prod
datasource:
url: ${DB_URL} # real database in production
jpa:
hibernate:
ddl-auto: validateScheduling & Async
Spring Boot supports declarative scheduled tasks and asynchronous method execution.
Enable them once with @EnableScheduling and @EnableAsync,
then annotate individual methods.
@SpringBootApplication
@EnableScheduling
public class MyApplication { ... }
@Service
public class ScheduledTasks {
// Fixed delay — waits 5 s after each completion before starting the next
@Scheduled(fixedDelay = 5_000)
public void pollExternalService() {
externalApi.fetchUpdates().forEach(this::processUpdate);
}
// Fixed rate — fires every 10 s regardless of how long execution takes
@Scheduled(fixedRate = 10_000)
public void heartbeat() {
log.info("Service alive at {}", LocalDateTime.now());
}
// Cron — every day at 02:00 UTC
@Scheduled(cron = "0 0 2 * * *", zone = "UTC")
public void nightly() {
reportService.generate();
cleanupService.purgeOldRecords();
}
// Dynamic cron from config
@Scheduled(cron = "${tasks.sync.cron:0 */15 * * * *}")
public void syncFromPartner() {
partnerService.sync();
}
}@SpringBootApplication
@EnableAsync
public class MyApplication { ... }
// Custom thread pool (optional — recommended for production)
@Configuration
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(4);
exec.setMaxPoolSize(16);
exec.setQueueCapacity(100);
exec.setThreadNamePrefix("async-");
exec.initialize();
return exec;
}
}
@Service
public class NotificationService {
// Returns immediately; Spring runs this on a separate thread
@Async("taskExecutor")
public CompletableFuture<Void> sendEmailAsync(String to, String body) {
emailClient.send(to, body); // may take a few seconds
return CompletableFuture.completedFuture(null);
}
@Async
public void sendPushNotificationAsync(Long userId, String message) {
pushGateway.send(userId, message);
}
}
// Controller — fire-and-forget, responds instantly
@PostMapping("/notify/{userId}")
@ResponseStatus(HttpStatus.ACCEPTED)
public void notify(@PathVariable Long userId, @RequestBody String message) {
notificationService.sendPushNotificationAsync(userId, message);
// returns 202 immediately while notification sends in background
}Spring Boot Actuator
Actuator exposes production-ready HTTP endpoints that give you live insight into your running application — database connectivity, JVM memory, HTTP metrics, bean wiring, and more. Add the starter and configure which endpoints to expose.
| Endpoint | Description | Default exposure |
|---|---|---|
| /actuator/health | Application + dependency health (DB, disk, cache) | exposed |
| /actuator/info | App version, git commit, custom info | exposed |
| /actuator/metrics | JVM, HTTP request latency, custom counters | disabled |
| /actuator/env | All resolved config properties and their sources | sensitive |
| /actuator/beans | Every bean registered in the ApplicationContext | disabled |
| /actuator/loggers | View and change log levels at runtime | disabled |
| /actuator/httptrace | Last 100 HTTP requests/responses | disabled |
management:
endpoints:
web:
exposure:
include: health,info,metrics,loggers # comma-separated or "*" for all
endpoint:
health:
show-details: when-authorized # or "always" / "never"
info:
env:
enabled: true
# Expose build info in /actuator/info
info:
app:
name: ${spring.application.name}
version: "@project.version@" # injected from pom.xml at build// Custom health indicator — appears under /actuator/health
@Component
public class ExternalApiHealthIndicator implements HealthIndicator {
private final ExternalApiClient apiClient;
@Override
public Health health() {
try {
boolean ok = apiClient.ping();
return ok
? Health.up().withDetail("api", "reachable").build()
: Health.down().withDetail("api", "unreachable").build();
} catch (Exception ex) {
return Health.down()
.withDetail("api", "error")
.withException(ex)
.build();
}
}
}
// Custom metric counter
@Service
public class OrderService {
private final Counter orderCounter;
public OrderService(MeterRegistry registry) {
this.orderCounter = Counter.builder("orders.created")
.description("Total orders placed")
.register(registry);
}
@Transactional
public Order placeOrder(PlaceOrderRequest req) {
Order order = // ... create order
orderCounter.increment();
return order;
}
}Testing
Spring Boot ships first-class testing support via
spring-boot-starter-test, which bundles JUnit 5, Mockito, AssertJ,
and MockMvc. Tests fall into three categories by scope.
// Pure unit test — no Spring context loaded, very fast
class UserServiceTest {
@Mock UserRepository userRepository;
@Mock PasswordEncoder passwordEncoder;
@InjectMocks UserService userService;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@Test
void shouldReturnUserWhenFound() {
User user = new User(1L, "Alice", "alice@example.com");
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
UserDto result = userService.findById(1L);
assertThat(result.name()).isEqualTo("Alice");
verify(userRepository, times(1)).findById(1L);
}
@Test
void shouldThrowWhenUserNotFound() {
when(userRepository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.findById(99L))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessageContaining("User not found with id: 99");
}
}// @WebMvcTest loads ONLY the web layer (no JPA, no full context)
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired MockMvc mvc;
@MockBean UserService userService; // replaced with Mockito mock
@Test
void getUser_returns200() throws Exception {
UserDto dto = new UserDto(1L, "Alice", "alice@example.com", LocalDateTime.now());
when(userService.findById(1L)).thenReturn(dto);
mvc.perform(get("/api/v1/users/1").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Alice"))
.andExpect(jsonPath("$.email").value("alice@example.com"));
}
@Test
void createUser_returns400_whenEmailInvalid() throws Exception {
String body = """{"name":"Bob","email":"not-an-email","password":"Secure1!"}""";
mvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
}// Full Spring context + in-memory H2 database
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = Replace.ANY) // swap to H2
class UserIntegrationTest {
@Autowired TestRestTemplate restTemplate;
@Autowired UserRepository userRepository;
@BeforeEach void setup() { userRepository.deleteAll(); }
@Test
void createAndFetchUser() {
CreateUserRequest req = new CreateUserRequest("Alice","alice@test.com","Secret1!");
ResponseEntity<UserDto> createResp =
restTemplate.postForEntity("/api/v1/users", req, UserDto.class);
assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Long id = createResp.getBody().id();
ResponseEntity<UserDto> getResp =
restTemplate.getForEntity("/api/v1/users/" + id, UserDto.class);
assertThat(getResp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResp.getBody().email()).isEqualTo("alice@test.com");
}
}Annotations cheatsheet
Stereotype & component
| Annotation | Layer | Purpose |
|---|---|---|
| @SpringBootApplication | Bootstrap | Combines @Configuration + @EnableAutoConfiguration + @ComponentScan |
| @Component | Any | Generic Spring-managed bean; discovered by component scan |
| @Service | Business | Marks a service bean; communicates business-logic intent |
| @Repository | Data | Marks a DAO; wraps DB exceptions in Spring's DataAccessException |
| @Controller | Presentation | Marks an MVC controller returning views |
| @RestController | Presentation | = @Controller + @ResponseBody; returns JSON/XML directly |
| @Configuration | Config | Class contains @Bean factory methods |
| @Bean | Config | Declares a single bean inside a @Configuration class |
Dependency injection
| Annotation | Purpose |
|---|---|
| @Autowired | Inject a bean by type (prefer constructor injection over field injection) |
| @Qualifier("name") | Resolve ambiguity when multiple beans of the same type exist |
| @Primary | Mark a bean as the default when multiple candidates exist |
| @Value("${key}") | Inject a single config property as a field |
| @Lazy | Initialize the bean only when first requested (not at startup) |
| @Scope("prototype") | Create a new bean instance for each injection point |
Web / MVC
| Annotation | Purpose |
|---|---|
| @RequestMapping | Base URL prefix for a controller class or a handler method |
| @GetMapping / @PostMapping / @PutMapping / @DeleteMapping / @PatchMapping | HTTP-method-specific shorthand for @RequestMapping |
| @PathVariable | Bind a URI template variable {id} to a method parameter |
| @RequestParam | Bind a query string parameter to a method parameter |
| @RequestBody | Deserialize the HTTP request body (JSON → Java) |
| @ResponseBody | Serialize the return value to the HTTP response body |
| @ResponseStatus | Set the default HTTP status code for the method |
| @CrossOrigin | Enable CORS for a controller or endpoint |
| @ExceptionHandler | Handle a specific exception inside a controller or @ControllerAdvice |
| @ControllerAdvice / @RestControllerAdvice | Global exception handling / model augmentation for all controllers |
JPA / persistence
| Annotation | Purpose |
|---|---|
| @Entity | Marks a class as a JPA entity mapped to a database table |
| @Table(name="...") | Customize the mapped table name |
| @Id | Marks the primary key field |
| @GeneratedValue | Configure primary key generation strategy (IDENTITY, SEQUENCE, AUTO) |
| @Column | Customize column name, nullable, unique, length constraints |
| @OneToMany / @ManyToOne / @OneToOne / @ManyToMany | Declare entity relationships |
| @JoinColumn | Specify the foreign key column for a relationship |
| @Transactional | Wrap a method or class in a database transaction |
| @Query | Provide a custom JPQL or native SQL query on a repository method |
Component correlation
All components are wired together by the IoC container following a strict unidirectional dependency rule. Understanding this chain is the foundation of maintainable Spring Boot applications.
- Client sends an HTTP request. Spring Security's filter chain authenticates and authorizes it before any controller code runs.
-
DispatcherServlet receives the request and routes it to the
matching
@RestControllermethod based on@RequestMapping. -
Controller validates input (
@Valid) and delegates to a@Service. It never accesses the database directly. -
Service applies business rules, manages transactions
(
@Transactional), and calls one or more@Repositoryinterfaces. - Repository runs SQL via JPA/Hibernate against the DataSource. Spring Data generates the implementation; you only write the interface.
- The result travels back up the chain. The controller returns a DTO; Jackson serializes it to JSON; the response goes to the client.
-
If any layer throws an exception, @ControllerAdvice intercepts
it and returns a consistent
ErrorResponseJSON — no stack trace exposed to the client.
Constructor injection
Always inject dependencies through the constructor, not @Autowired on fields. Easier to test, dependencies are explicit and final.
Never expose entities
Always map @Entity objects to DTOs before returning from a controller. Entities carry Hibernate proxies and lazy-load traps.
One @ControllerAdvice
Keep a single global exception handler class. It becomes the API's error contract — a consistent shape every consumer can rely on.