Checked exceptions (must be caught or declared with throws):
- Extends
Exceptionbut NOTRuntimeException - Represent recoverable, expected conditions
- Examples:
IOException,SQLException,ClassNotFoundException
public void readFile(String path) throws IOException { // must declare
Files.readAllBytes(Path.of(path));
}
Unchecked exceptions (RuntimeException subclasses):
- No requirement to catch or declare
- Represent programming errors or unrecoverable situations
- Examples:
NullPointerException,IllegalArgumentException,ArrayIndexOutOfBoundsException
Controversy: Checked exceptions are unique to Java. Many argue they clutter APIs and encourage catch (Exception e) {} anti-patterns. Spring and most modern libraries convert checked to unchecked. Kotlin and C# don't have checked exceptions.
Throwable
├── Error (serious JVM/system problems, don't catch)
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ ├── VirtualMachineError
│ └── AssertionError
└── Exception (application-level problems)
├── IOException (checked)
├── SQLException (checked)
└── RuntimeException (unchecked)
├── NullPointerException
├── IllegalArgumentException
└── ...
Errors: Abnormal conditions in JVM itself. Not caused by application code. Recovery is impossible or impractical. Don't catch Errors (except in very specific frameworks like test runners catching AssertionError).
Exceptions: Problems in application logic. Checked = expected, recoverable. Unchecked = programming errors, typically unrecoverable in context.
RuntimeException provides a category for programming errors that:
- Can occur at any point in code
- Would be impractical to declare on every method
- Typically indicate bugs rather than external conditions
If all runtime errors were checked:
NullPointerExceptionwould have to be declared on every method that uses an object referenceClassCastExceptionon every cast- This would make Java code completely unmanageable
RuntimeException says: "This is the caller's fault for misusing the API. Handle it at a high level or let the application fail."
// Custom checked exception
public class UserNotFoundException extends Exception {
private final Long userId;
public UserNotFoundException(Long userId) {
super("User not found with ID: " + userId);
this.userId = userId;
}
// Exception chaining
public UserNotFoundException(Long userId, Throwable cause) {
super("User not found with ID: " + userId, cause);
this.userId = userId;
}
public Long getUserId() { return userId; }
}
// Custom unchecked exception
public class InsufficientFundsException extends RuntimeException {
private final BigDecimal required;
private final BigDecimal available;
public InsufficientFundsException(BigDecimal required, BigDecimal available) {
super(String.format("Required: %s, Available: %s", required, available));
this.required = required;
this.available = available;
}
}
Best practices:
- Provide constructors with message and cause (for chaining)
- Add relevant context fields
- Make unchecked unless caller can meaningfully recover
- Declare
serialVersionUIDfor serializable exceptions
- Be specific: Catch specific exceptions, not
ExceptionorThrowable - Don't swallow: Never
catch (Exception e) {}– at minimum log the exception - Preserve cause: Always use exception chaining when wrapping:
new ServiceException("msg", originalException) - Fail fast: Throw early when preconditions fail (IllegalArgumentException, NullPointerException)
- Document with @throws: In Javadoc, document what exceptions and when
- Don't use exceptions for control flow:
try { parse() } catch (NumberFormatException)as normal flow is bad - Clean up in finally/try-with-resources: Don't rely on exception path to close resources
- Log once: Log at the point of handling, not at every re-throw
- Don't catch what you can't handle: Let it propagate to a level that can handle it
- RuntimeException for API misuse: IllegalArgument, IllegalState, NullPointer for programming errors
Exceptions propagate up the call stack until caught or the program terminates.
main() → serviceMethod() → daoMethod() → throws IOException
↑ propagates
serviceMethod() → if not caught, propagates to main()
main() → if not caught, JVM prints stack trace and exits
Checked exceptions: Must be caught or declared at each level in the call stack.
Unchecked: Propagate automatically without declaration.
void dao() throws IOException { throw new IOException("DB Error"); }
void service() throws IOException { dao(); } // propagates
void controller() {
try { service(); }
catch (IOException e) { throw new ServiceException("Failed", e); } // wrap and re-throw
}
A stack trace is a snapshot of the call stack at the moment an exception was thrown:
java.lang.NullPointerException: Cannot invoke "String.length()" because "str" is null
at com.example.UserService.processName(UserService.java:45)
at com.example.UserController.handleRequest(UserController.java:23)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:897)
...
Reading a stack trace:
- First line: Exception type and message
- Second line: Immediate location where exception was thrown
- Subsequent lines: Call chain (most recent first)
Java 14+ Helpful NullPointerExceptions: JVM can tell you exactly which part of an expression was null.
Performance: new Exception() captures stack trace (expensive). new RuntimeException(message, cause, false, false) disables stack trace collection (use in high-performance exception factories).
finally block always executes after try/catch, regardless of:
- Normal completion
- Exception thrown (caught or not)
returnorbreakin try block
int test() {
try {
return 1;
} finally {
System.out.println("finally runs"); // prints before return
// return 2; // DON'T: this swallows the original return/exception
}
}
Exceptions: System.exit(), JVM crash, thread kill – finally may NOT run.
try-with-resources is preferred over finally for resource cleanup (simpler, handles suppressed exceptions better).
If both try and finally have return:
String test() {
try {
return "try"; // scheduled
} finally {
return "finally"; // overrides! "try" return is LOST
}
}
test(); // returns "finally"
Similarly, if finally throws an exception, it overrides the try-block exception (original is lost unless captured and attached as suppressed).
Best practice: Never put return or throw in finally. It masks exceptions and makes code hard to reason about.
In try-with-resources, if both body and close() throw exceptions, the close() exception is suppressed and attached to the primary exception:
try (Resource r = new Resource()) {
throw new RuntimeException("body exception");
// r.close() also throws: "close exception"
}
// Caught: RuntimeException("body exception")
// exception.getSuppressed()[0] = RuntimeException("close exception")
Manual suppression:
Throwable primary = null;
try {
doWork();
} catch (Throwable t) {
primary = t;
} finally {
try { close(); }
catch (Throwable t2) {
if (primary != null) primary.addSuppressed(t2);
else throw t2;
}
}
if (primary != null) throw primary;
try-with-resources handles this automatically – that's one reason to prefer it.
Exception chaining preserves the original cause when wrapping exceptions:
try {
statement.executeQuery(sql);
} catch (SQLException e) {
// Wrap with context, preserve cause
throw new DataAccessException("Failed to fetch users", e);
}
Access the chain:
catch (DataAccessException e) {
Throwable cause = e.getCause(); // original SQLException
cause.getCause(); // could be deeper chain
}
Constructors for chaining: Exception(String message, Throwable cause).
Why it matters: Without chaining, the original stack trace is lost. With chaining, you can trace the full execution path from root cause to high-level error.
Compiles to bytecode roughly equivalent to:
// try (Resource r = acquire()) { body }
Resource r = acquire();
Throwable primaryException = null;
try {
body;
} catch (Throwable t) {
primaryException = t;
throw t;
} finally {
if (r != null) {
if (primaryException != null) {
try { r.close(); }
catch (Throwable t) { primaryException.addSuppressed(t); }
} else {
r.close();
}
}
}
The key difference from manual try-finally:
- Close exceptions are suppressed (not swallowed, not masking primary)
- Resource is guaranteed closed
- Works with multiple resources (closed in reverse order)
Closeable | AutoCloseable | |
|---|---|---|
| Introduced | Java 5 | Java 7 |
| Extends | - | - |
Closeable extends | AutoCloseable | - |
close() throws | IOException | Exception |
| Multiple closes | Should be idempotent | Not required |
| Use in TWR | Yes | Yes |
Closeable is for I/O resources (specifically throws IOException).
AutoCloseable is more general (throws Exception).
All java.io classes implement Closeable, which extends AutoCloseable, so all work with try-with-resources.
For custom resources: implement AutoCloseable (or Closeable for I/O). Make close() idempotent when possible (safe to call multiple times).
- You can't handle it meaningfully: Don't catch just to re-throw the same exception
- Let framework handle it: Spring's
@ExceptionHandler, JAX-RS exception mappers - Test code: Don't catch in tests – let JUnit/TestNG report failures
- Logging noise: Don't catch, log, and re-throw – log once at the handling point
- Checked exceptions in streams/lambdas: Wrapping is cumbersome; often let it propagate
Anti-pattern:
try {
return service.process(request);
} catch (Exception e) {
log.error("Error", e);
throw e; // re-throwing - logger should be at higher level
}
// CORRECT: Log with exception (full stack trace)
log.error("Failed to process order {}", orderId, exception);
// WRONG: Only log message (no stack trace)
log.error("Failed to process order {}: {}", orderId, exception.getMessage());
// WRONG: System.out.println
System.out.println("Error: " + exception); // no proper logging
// WRONG: Log and throw (duplicate logs up the chain)
log.error("Error", e);
throw new ServiceException("Error", e); // caller will log too
Structured logging:
log.error("Failed to process order",
kv("orderId", orderId),
kv("userId", userId),
exception);
Log once: Log at the point of final handling, not at every intermediate catch-and-rethrow.
// Rethrow same exception (no wrapping)
try { ... }
catch (IOException e) {
cleanup();
throw e; // same exception, original stack trace preserved
}
// Wrap (exception translation)
try { ... }
catch (SQLException e) {
throw new DataAccessException("DB error", e); // wrapped with cause
}
// Java 7 precise rethrow
try { mightThrowChecked(); }
catch (Exception e) {
// compiler knows only declared checked exceptions can reach here
throw e; // no need to declare Exception on method, only declared checked types
}
Lambdas are tricky with checked exceptions – functional interfaces don't declare throws:
// Compile error: Function doesn't declare throws IOException
Function<String, String> fn = path -> Files.readString(Path.of(path));
// Option 1: Wrap in unchecked
Function<String, String> fn = path -> {
try { return Files.readString(Path.of(path)); }
catch (IOException e) { throw new UncheckedIOException(e); }
};
// Option 2: Helper method that wraps checked
@FunctionalInterface
interface CheckedFunction<T, R> { R apply(T t) throws Exception; }
static <T, R> Function<T, R> wrap(CheckedFunction<T, R> fn) {
return t -> { try { return fn.apply(t); } catch (Exception e) { throw new RuntimeException(e); } };
}
Function<String, String> fn = wrap(path -> Files.readString(Path.of(path)));
Libraries like Lombok (@SneakyThrows), Vavr, and others provide utilities for this.
In Spring Boot REST:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(UserNotFoundException e) {
return new ErrorResponse("USER_NOT_FOUND", e.getMessage());
}
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(ConstraintViolationException e) {
return new ErrorResponse("VALIDATION_ERROR", buildViolationMessage(e));
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneral(Exception e, HttpServletRequest request) {
log.error("Unhandled exception for {}", request.getRequestURI(), e);
return new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred");
}
}
Thread.UncaughtExceptionHandler:
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
log.error("Uncaught exception in thread {}", thread.getName(), throwable);
// alerting, metrics, etc.
});
Exceptions are expensive:
- Stack trace capture (
fillInStackTrace()): Walks the JVM stack, creates StackTraceElement objects. O(depth) cost. - Object creation:
new Exception()involves heap allocation + GC pressure - JIT deoptimization: Paths with exception handling may be compiled less aggressively
Benchmarks: A thrown/caught exception is ~100-1000x slower than a normal return, depending on stack depth.
Best practices:
- Don't use exceptions for control flow (e.g., use
Optionalinstead of catching NPE) - Pre-validate: Check
isPresent()beforeget()rather than catchingNoSuchElementException - Expensive in tight loops:
Integer.parseInt()+ catch is slow; useNumberUtils.isNumeric()first - Disable stack trace: For exceptions used as signals (not bugs), override
fillInStackTrace():
class FastException extends RuntimeException {
@Override public synchronized Throwable fillInStackTrace() { return this; }
}
assert condition : "message"; – throws AssertionError if condition is false (when assertions enabled with -ea).
Appropriate uses:
- Internal invariants: Pre-conditions for private methods (you control callers)
- Post-conditions: Verify result after complex computation
- Unreachable code:
default: assert false : "Unknown state: " + state; - Class/loop invariants
NOT appropriate:
- Public method parameter validation (use
IllegalArgumentException) - Business logic checks (use exceptions)
- Production code where assertions are disabled (default)
Assertions are disabled by default in production JVMs. Use IllegalArgumentException/IllegalStateException for code-correctness checks that should always run.
| Assertion | Exception | |
|---|---|---|
| Use | Programmer errors, invariants | Expected/unexpected runtime conditions |
| Default | Disabled in production | Always active |
| Type | AssertionError (Error) | Exception subclass |
| Public API | No (use IAE) | Yes |
| Recovery | No (programming bug) | Yes (if checked) |
Rule of thumb: Assertions document invariants for developers. Exceptions handle conditions for callers.
Pros:
- Compiler forces callers to handle/declare
- Self-documenting API: method signature shows failure modes
- Forces recovery strategy consideration
- Type-safe error handling
Cons:
- Clutters method signatures (
throws A, B, C, D) - Callers often swallow them:
catch (Exception e) { } - Breaks with lambdas/streams
- Spreads checked exception declarations through layers
- Controversy: C#, Kotlin, Scala, modern Java frameworks avoid them
Senior take: Use checked exceptions sparingly. If the caller can genuinely do something different for this exception (retry, fallback, user message), it might warrant checked. Otherwise, unchecked is cleaner. Spring's philosophy: convert all infrastructure exceptions (JDBC, JMS) to unchecked (DataAccessException hierarchy).
Converting low-level/infrastructure exceptions to higher-level/domain exceptions:
// DAO layer
try {
return jdbcTemplate.queryForObject(sql, params, User.class);
} catch (EmptyResultDataAccessException e) {
throw new UserNotFoundException(id); // translate to domain exception
} catch (DataAccessException e) {
throw new DataStoreException("Failed to fetch user " + id, e); // translate
}
Why:
- Abstraction: Callers shouldn't need to know you use JDBC/JPA
- Encapsulation: Implementation details (SQL errors) don't leak
- Layer responsibility: DAL translates DB errors; service layer translates to business errors
- Testability: Can test service with mock that throws domain exceptions
- Checked for expected failures:
findUser()→throws UserNotFoundExceptionif not finding is a normal case callers must handle - Unchecked for programming errors:
setAge(-1)→throw new IllegalArgumentException("Age must be non-negative") - Document what you throw: Javadoc
@throws - Consistent naming:
XxxExceptionfor checked,XxxException extends RuntimeExceptionfor unchecked, or just clear naming - Don't throw from constructors (leaking partially constructed objects) unless unavoidable
- Don't throw from finalizers
- Prefer Optional over checked exception for "not found" scenarios in functional contexts
- Consider http-status-friendly exceptions for REST APIs
- Use
@ControllerAdvice/@RestControllerAdvicefor centralized exception handling - Transactional rollback: By default, Spring only rolls back on
RuntimeExceptionandError. Use@Transactional(rollbackFor = Exception.class)to also rollback on checked exceptions - DataAccessException: Catch Spring's
DataAccessException(unchecked) not rawSQLException - Don't expose internal exceptions in REST responses (security risk + poor UX)
- Consistent error response format:
{ "error": "USER_NOT_FOUND", "message": "...", "timestamp": "..." } - Spring Boot's
ErrorController: Customize/errorendpoint for HTML error pages - Validation exceptions: Handle
MethodArgumentNotValidExceptionfor@Validfailures - Log with correlation ID: Include request ID in exception logs for traceability
- Propagation in async:
@Asyncmethods lose exception context; useAsyncUncaughtExceptionHandler - Circuit breaker: Use Resilience4j to handle repeated failures gracefully
// Getting context (rarely needed in Spring Boot apps)
ApplicationContext ctx = SpringApplication.run(MyApp.class, args);
UserService service = ctx.getBean(UserService.class);
// In a bean, inject context
@Component
class MyComponent implements ApplicationContextAware {
private ApplicationContext context;
@Override
public void setApplicationContext(ApplicationContext ctx) {
this.context = ctx;
}
}
Hierarchy: Spring Boot creates a parent/child context. Parent context has non-web beans. Child (Web ApplicationContext) has web-specific beans (controllers, filters). Beans in parent visible to child but not vice versa.
Environment: ApplicationContext wraps Environment which holds properties, profiles, active environment.
Complete bean lifecycle:
1. Instantiation (constructor called) 2. Property injection (setter/field injection, @Autowired) 3. BeanNameAware.setBeanName() 4. BeanFactoryAware.setBeanFactory() 5. ApplicationContextAware.setApplicationContext() 6. BeanPostProcessor.postProcessBeforeInitialization() [all beans] 7. @PostConstruct method 8. InitializingBean.afterPropertiesSet() 9. @Bean(initMethod = "init") custom init method 10. BeanPostProcessor.postProcessAfterInitialization() [all beans] → Bean ready for use ... 11. @PreDestroy method 12. DisposableBean.destroy() 13. @Bean(destroyMethod = "cleanup") custom destroy method
BeanPostProcessor is the extension point used by Spring AOP (creates proxies), @Async, @Transactional, etc.
@Component (and specializations @Service, @Repository, @Controller):
- Class-level annotation
- Detected by component scanning
- Spring instantiates the class directly
- For your own classes
@Bean:
- Method-level annotation inside
@Configurationclass - You write the instantiation code
- For third-party classes you can't annotate
- More control over construction
// @Component - Spring creates instance
@Service
public class UserService { ... }
// @Bean - you create instance (for third-party)
@Configuration
public class AppConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return mapper;
}
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder.setConnectTimeout(Duration.ofSeconds(5)).build();
}
}
@Configuration classes use CGLIB proxying: @Bean methods called directly return the same bean instance (singleton). Use @Configuration(proxyBeanMethods = false) for lighter-weight config when inter-bean method calls not needed.
Constructor injection (recommended):
@Service
public class OrderService {
private final OrderRepository repo;
private final PaymentService payment;
// @Autowired optional if single constructor (Spring 4.3+)
public OrderService(OrderRepository repo, PaymentService payment) {
this.repo = repo;
this.payment = payment;
}
}
Setter injection:
@Service
public class OrderService {
private OrderRepository repo;
@Autowired
public void setRepo(OrderRepository repo) { this.repo = repo; }
}
Field injection (least recommended):
@Service
public class OrderService {
@Autowired private OrderRepository repo; // not testable without Spring context
}
Problems with @Autowired field injection:
- Not testable: Can't inject mocks without Spring context or reflection; with constructor you can
new OrderService(mockRepo) - Hides dependencies: Looking at constructor tells you exactly what's needed
- Allows circular dependencies (masks bad design); constructor injection fails fast
- Can't make fields
final: Breaks immutability - Reflection-based: Slower, bypasses normal Java visibility
Constructor injection advantages:
- Fields can be
final(immutable, fail-fast if null) - Obvious dependencies (API contract)
- Works without Spring (testable in isolation)
- Forces small, focused classes (many constructor params = code smell)
- No circular dependencies unless you use
@Lazy
Official recommendation: Constructor injection is the Spring Team's preferred approach (since Spring 4.3).
Profiles allow different beans/configurations for different environments:
@Configuration
@Profile("prod")
public class ProdConfig { ... }
@Configuration
@Profile("!prod") // not prod
public class DevConfig { ... }
@Bean
@Profile({"dev", "test"})
public DataSource h2DataSource() { ... }
@Bean
@Profile("prod")
public DataSource prodDataSource() { ... }
Activate profiles:
# application.properties
spring.profiles.active=prod
# Command line
java -jar app.jar --spring.profiles.active=prod
# Environment variable
SPRING_PROFILES_ACTIVE=prod
# JVM property
-Dspring.profiles.active=prod
# Programmatic
SpringApplication app = new SpringApplication(MyApp.class);
app.setAdditionalProfiles("prod");
Profile-specific properties: application-prod.yml, application-dev.yml.
Spring Boot loads properties from many sources in priority order (later overrides earlier):
- Default properties (
SpringApplication.setDefaultProperties) @PropertySourceannotationsapplication.properties/application.yml- Profile-specific:
application-{profile}.properties - OS environment variables
- Java system properties (
-D) - Command line arguments (
--property=value) ← highest priority
// Injecting properties
@Value("${app.name:MyApp}") // with default "MyApp"
private String appName;
// Typed configuration
@ConfigurationProperties(prefix = "app.mail")
@Validated
public class MailProperties {
@NotBlank private String host;
private int port = 25;
private boolean ssl;
// getters/setters
}
@SpringBootApplication
@EnableConfigurationProperties(MailProperties.class)
public class App { }
Spring Boot 2.4+: Config data API, spring.config.import to import from other files/vaults.
Both configure the same properties:
# application.properties spring.datasource.url=jdbc:mysql://localhost:3306/mydb spring.datasource.username=root server.port=8080
# application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
username: root
server:
port: 8080
YAML advantages:
- Less repetition of common prefixes
- Supports lists and maps more naturally
- Multiple profiles in one file using
---document separator - More readable for nested config
# application.yml with multiple profiles
spring:
profiles:
active: dev
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:h2:mem:testdb
spring:
config:
activate:
on-profile: prod
datasource:
url: jdbc:mysql://prod-server:3306/mydb
YAML caveat: YAML is sensitive to indentation. Properties is simpler and less error-prone.
Spring Boot Actuator provides production-ready endpoints for monitoring and management:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Key endpoints (/actuator/{id}):
/health: Application health (UP/DOWN + component details)/info: Application info (build, git commit, etc.)/metrics: Micrometer metrics (JVM, HTTP, custom)/prometheus: Prometheus-format metrics/env: Environment properties (sanitized)/loggers: View/change log levels at runtime/threaddump: Current thread dump/heapdump: Download heap dump/beans: All Spring beans/mappings: All URL mappings/auditevents: Security audit events/refresh: (Cloud) Reload config
# Expose all endpoints (restrict in production!)
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
/actuator/health aggregates health indicators:
Built-in indicators: DB, Redis, Kafka, Rabbit, Disk space, Elasticsearch, Mail, etc.
// Custom health indicator
@Component
public class ExternalApiHealthIndicator implements HealthIndicator {
@Override
public Health health() {
try {
ResponseEntity<String> response = restTemplate.getForEntity(apiUrl, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
return Health.up()
.withDetail("url", apiUrl)
.withDetail("responseTime", responseTime)
.build();
}
return Health.down().withDetail("status", response.getStatusCode()).build();
} catch (Exception e) {
return Health.down(e).build();
}
}
}
Kubernetes integration:
management:
endpoint:
health:
probes:
enabled: true # enables /actuator/health/liveness and /actuator/health/readiness
Default: Logback with SLF4J facade.
// SLF4J (facade - don't import Logback directly)
private static final Logger log = LoggerFactory.getLogger(UserService.class);
// Or with Lombok:
@Slf4j
public class UserService { }
log.debug("Processing user {}", userId);
log.info("User {} created", userId);
log.warn("Slow DB query: {}ms", elapsed);
log.error("Failed to process user {}", userId, exception);
Configuration in application.yml:
logging:
level:
root: INFO
com.example: DEBUG
org.hibernate.SQL: DEBUG # log SQL
org.hibernate.type: TRACE # log SQL parameters
file:
name: /var/log/myapp.log
pattern:
console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
Custom logback-spring.xml for advanced configuration (rolling files, async appenders, MDC).
| Logback | Log4j2 | |
|---|---|---|
| Default in Spring Boot | Yes | No (exclude and add) |
| Performance | Good | Better (async appenders) |
| Async appenders | AsyncAppender | AsyncLogger (LMAX Disruptor) |
| JSON logging | Via logstash-logback-encoder | Built-in |
| Configuration | logback-spring.xml | log4j2-spring.xml |
| Hot reload | Yes | Yes |
| Security | Log4Shell vulnerability (2021) in Log4j2 2.x | CVE-2021-44228 (fixed in 2.17+) |
Switch to Log4j2:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
For high-throughput services: Log4j2 async loggers are significantly faster.
Option 1: @RestControllerAdvice (recommended):
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ProblemDetail handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
pd.setTitle("Resource Not Found");
pd.setDetail(ex.getMessage());
pd.setInstance(URI.create(req.getRequestURI()));
return pd;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Validation Failed");
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(e ->
errors.put(e.getField(), e.getDefaultMessage()));
pd.setProperty("errors", errors);
return pd;
}
}
ProblemDetail (RFC 7807, Spring 6 / Spring Boot 3): Standard error response format.
Option 2: ResponseEntityExceptionHandler: Extend for default Spring MVC exception handling.
@ControllerAdvice applies cross-cutting concerns to multiple controllers:
- Exception handling:
@ExceptionHandlermethods - Model attributes:
@ModelAttributemethods (adds to all model maps) - Data binding:
@InitBindermethods (customize data binding)
@RestControllerAdvice = @ControllerAdvice + @ResponseBody
Scoping:
@ControllerAdvice(assignableTypes = {UserController.class, OrderController.class})
@ControllerAdvice(basePackages = "com.example.api")
@ControllerAdvice(annotations = RestController.class)
Order: Multiple @ControllerAdvice can coexist; use @Order to prioritize.
Spring Boot uses Bean Validation (JSR 380 / Jakarta Validation) via Hibernate Validator:
// DTO with constraints
public record CreateUserRequest(
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100)
String name,
@Email
@NotNull
String email,
@Min(18) @Max(120)
int age,
@Pattern(regexp = "^\\+?[1-9]\\d{1,14}$")
String phone
) {}
// Controller - @Valid triggers validation
@PostMapping("/users")
public UserDTO createUser(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
// Service-level validation
@Service
@Validated
public class UserService {
public void updateEmail(@Valid @Email String email, Long userId) { ... }
}
Groups: Validate different constraints in different scenarios (@Validated(CreateGroup.class)).
Custom validator:
@Target(FIELD) @Retention(RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "Email already exists";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Spring Data JPA eliminates boilerplate DAO code:
// Repository - just declare the interface
public interface UserRepository extends JpaRepository<User, Long> {
// Derived query from method name
List<User> findByEmailAndActive(String email, boolean active);
// Custom JPQL
@Query("SELECT u FROM User u WHERE u.age > :minAge ORDER BY u.name")
List<User> findUsersOlderThan(@Param("minAge") int minAge);
// Native SQL
@Query(value = "SELECT * FROM users WHERE region = ?1", nativeQuery = true)
List<User> findByRegionNative(String region);
// Pagination
Page<User> findByDepartment(String dept, Pageable pageable);
// Projection
List<UserNameEmail> findAllProjectedBy();
// Modifying
@Modifying
@Transactional
@Query("UPDATE User u SET u.active = false WHERE u.lastLogin < :cutoff")
int deactivateOldUsers(@Param("cutoff") LocalDateTime cutoff);
}
JpaRepository provides: save(), findById(), findAll(), delete(), count(), existsById(), pagination, sorting.
Spring Data offers multiple repository interfaces:
CrudRepository<T, ID>: Basic CRUDPagingAndSortingRepository<T, ID>: + pagination/sortingJpaRepository<T, ID>: + JPA-specific (flush, batch)
Custom base repository:
@NoRepositoryBean
public interface BaseRepository<T, ID> extends JpaRepository<T, ID> {
List<T> findByCreatedAfter(LocalDateTime date);
}
Specification pattern (for dynamic queries):
public class UserSpecifications {
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"));
}
}
// Use:
userRepository.findAll(
UserSpecifications.hasEmail(email).and(UserSpecifications.isActive())
);
Spring's @Transactional declarative transaction management:
@Service
@Transactional(readOnly = true) // default for all methods
public class OrderService {
@Transactional // overrides class default - read-write
public Order createOrder(CreateOrderRequest request) {
Order order = new Order(request);
order = orderRepository.save(order);
inventoryService.reserve(order); // participates in same transaction
return order;
}
@Transactional(
propagation = Propagation.REQUIRES_NEW, // new transaction always
isolation = Isolation.SERIALIZABLE,
timeout = 30, // seconds
rollbackFor = Exception.class, // rollback on all exceptions
noRollbackFor = InformationalException.class
)
public void auditOrder(Long orderId) { ... }
}
Propagation types:
REQUIRED(default): Join existing or create newREQUIRES_NEW: Always create new, suspend existingNESTED: Save point within existing transactionSUPPORTS: Join if exists, non-transactional if notMANDATORY: Must have existing transactionNEVER: Must NOT have existing transactionNOT_SUPPORTED: Suspend existing, run non-transactionally
- Self-invocation: Calling a
@Transactionalmethod from within the same class bypasses the proxy:
@Service
public class OrderService {
public void process() {
createOrder(); // NO transaction! Self-call bypasses proxy
}
@Transactional
public void createOrder() { ... }
}
// Fix: Inject self, or move to separate class, or use AspectJ mode
- Non-public methods:
@Transactionalon private/protected/package methods is ignored (proxy limitation).
- Checked exceptions don't rollback by default: Only
RuntimeExceptionandError. UserollbackFor = Exception.class.
- Catching exception inside transaction: If you catch and swallow the exception, no rollback:
@Transactional
void method() {
try { riskyOperation(); }
catch (Exception e) { log.error(e); } // COMMIT happens! No rollback
}
- readOnly misuse:
readOnly = trueis a hint to optimize; doesn't prevent writes. Use it for query-only methods.
- Propagation.REQUIRES_NEW + exception handling: Exception in outer transaction doesn't rollback inner, and vice versa.
Eager loading: Fetch associated entities immediately when parent is loaded.
Lazy loading: Fetch associated entities only when accessed.
@Entity
public class Order {
@ManyToOne(fetch = FetchType.EAGER) // always loaded
private Customer customer;
@OneToMany(fetch = FetchType.LAZY) // loaded on access
private List<OrderItem> items;
}
JPA defaults: @OneToMany and @ManyToMany default to LAZY. @ManyToOne and @OneToOne default to EAGER.
LazyInitializationException: Accessing a lazy collection outside a transaction (session is closed):
Order order = orderRepository.findById(id).get(); // Transaction closed here if method wasn't @Transactional order.getItems().size(); // LazyInitializationException!
Fixes:
@Transactionalon the service methodJOIN FETCHin JPQL:SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id@EntityGraph- Projections
Classic N+1: Load N entities, then execute 1 query per entity to load associations = N+1 queries total.
// N+1 problem
List<Order> orders = orderRepository.findAll(); // 1 query
for (Order o : orders) {
System.out.println(o.getCustomer().getName()); // N queries!
}
// Total: 1 + N queries
Solutions:
- JOIN FETCH:
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") String status);
- @EntityGraph:
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByStatus(String status);
- Batch fetching:
@BatchSize(size = 50)on association – fetches 50 at a time instead of 1.
- Projection/DTO: Fetch only needed data.
- Enable
spring.jpa.properties.hibernate.default_batch_fetch_size=50: Global batch fetching.
Detect N+1: Enable SQL logging, use datasource-proxy, Hibernate's statistics, tools like p6spy.
// Repository
public interface UserRepository extends JpaRepository<User, Long> {
Page<User> findByDepartment(String dept, Pageable pageable);
Slice<User> findByActive(boolean active, Pageable pageable); // no count query
}
// Service
Pageable pageable = PageRequest.of(
pageNumber, // 0-based
pageSize,
Sort.by("name").ascending().and(Sort.by("age").descending())
);
Page<User> page = userRepository.findByDepartment("Engineering", pageable);
page.getContent(); // List<User> for current page
page.getTotalElements(); // total count (runs COUNT query)
page.getTotalPages();
page.getNumber(); // current page
page.hasNext();
// REST controller
@GetMapping("/users")
public Page<UserDTO> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "name") String sort) {
Pageable pageable = PageRequest.of(page, size, Sort.by(sort));
return userRepository.findAll(pageable).map(userMapper::toDto);
}
Slice vs Page: Slice doesn't execute COUNT query (better performance when total count not needed – e.g., infinite scroll).
- Use proper HTTP methods: GET (read), POST (create), PUT (full update), PATCH (partial), DELETE
- Meaningful URIs:
/users/{id}/ordersnot/getUserOrders - Plural nouns:
/usersnot/user - HTTP status codes: 200 OK, 201 Created, 204 No Content, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable, 500 Internal Error
- Versioning:
/api/v1/usersorAccept: application/vnd.myapp.v1+json - Pagination: Consistent page/size parameters, include metadata
- Filtering/sorting: Query params:
/users?status=active&sort=name,asc - Consistent error responses: Problem Details (RFC 7807)
- Idempotency: PUT, DELETE should be idempotent
- HATEOAS: Link to related resources (see Q290)
- Security: Always validate input, use HTTPS, authentication on all endpoints
- Documentation: OpenAPI/Swagger
- Content negotiation: Support
application/json; potentially XML if required
HATEOAS (Hypermedia As The Engine Of Application State): REST responses include links to related actions/resources.
{
"id": 123,
"name": "Alice",
"email": "alice@example.com",
"_links": {
"self": { "href": "/api/users/123" },
"orders": { "href": "/api/users/123/orders" },
"update": { "href": "/api/users/123", "method": "PUT" },
"delete": { "href": "/api/users/123", "method": "DELETE" }
}
}
Spring HATEOAS:
@GetMapping("/users/{id}")
public EntityModel<UserDTO> getUser(@PathVariable Long id) {
UserDTO user = userService.findById(id);
return EntityModel.of(user,
linkTo(methodOn(UserController.class).getUser(id)).withSelfRel(),
linkTo(methodOn(OrderController.class).getUserOrders(id)).withRel("orders")
);
}
In practice: Full HATEOAS is rarely implemented in enterprise APIs. Self links and related resource links are the most common usage.
Spring Security added via spring-boot-starter-security. Auto-configures:
- HTTP Basic auth
- Default in-memory user
- CSRF protection
- Secure all endpoints
Custom configuration (Spring Boot 3 / Spring Security 6):
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // for REST APIs
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // strength 12
}
}
// JWT Filter
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
String username = jwtService.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
chain.doFilter(request, response);
}
}
// JWT Service
@Service
public class JwtService {
@Value("${jwt.secret}") private String secret;
@Value("${jwt.expiration:3600}") private long expiration;
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration * 1000))
.claim("roles", userDetails.getAuthorities())
.signWith(getSigningKey())
.compact();
}
}
Key security considerations:
- Store JWT secret securely (Vault, Secrets Manager)
- Short expiration + refresh token pattern
- Token invalidation/blacklist for logout
- Validate issuer, audience, not-before claims
- Use RS256 (asymmetric) over HS256 for microservices
Spring Security OAuth2 (Spring Boot 3):
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: openid, profile, email
resourceserver:
jwt:
issuer-uri: https://accounts.google.com
Resource Server (validate JWT from auth server):
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
);
return http.build();
}
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthoritiesClaimName("roles");
converter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
}
Grant types: Authorization Code (web), Client Credentials (machine-to-machine), Device Flow (IoT).
// Enable caching
@SpringBootApplication
@EnableCaching
public class App { }
// Use caching
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id", unless = "#result == null")
public Product getProduct(Long id) {
return repository.findById(id).orElse(null); // DB call only on cache miss
}
@CachePut(value = "products", key = "#product.id") // always execute + update cache
public Product updateProduct(Product product) {
return repository.save(product);
}
@CacheEvict(value = "products", key = "#id") // remove from cache
public void deleteProduct(Long id) {
repository.deleteById(id);
}
@CacheEvict(value = "products", allEntries = true) // clear entire cache
@Scheduled(fixedRate = 3600000) // hourly
public void evictAllProducts() { }
@Caching(evict = {
@CacheEvict("products"),
@CacheEvict(value = "productsByCategory", key = "#product.category")
})
public void processProduct(Product product) { }
}
Default cache: ConcurrentMapCacheManager (in-memory, no TTL). Configure TTL with Caffeine, Redis, etc.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring:
data:
redis:
host: localhost
port: 6379
password: ${REDIS_PASSWORD}
lettuce:
pool:
max-active: 10
Using as cache:
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.withCacheConfiguration("products",
RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(1)))
.build();
}
Using RedisTemplate directly:
@Autowired RedisTemplate<String, Object> redisTemplate;
redisTemplate.opsForValue().set("key", value, Duration.ofMinutes(30));
Object value = redisTemplate.opsForValue().get("key");
redisTemplate.opsForHash().put("hash", "field", "value");
redisTemplate.opsForList().leftPush("list", "element");
Session storage:
<dependency>spring-session-data-redis</dependency>
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
Spring Boot is a natural fit for microservices:
Principles:
- Single responsibility: one service per bounded context
- Independent deployability
- Own data store per service
- Communicate via HTTP/REST or messaging (Kafka, RabbitMQ)
Common patterns:
// Service discovery (Eureka)
@EnableEurekaClient
// Load balancing
@LoadBalanced
@Bean RestTemplate restTemplate() { return new RestTemplate(); }
// Feign client (declarative HTTP)
@FeignClient(name = "order-service")
public interface OrderClient {
@GetMapping("/orders/{id}")
OrderDTO getOrder(@PathVariable Long id);
}
// Config server
spring.config.import=configserver:http://config-server:8888
// Distributed tracing (Micrometer Tracing + Zipkin)
management.tracing.sampling.probability=1.0
Challenges: Network latency, eventual consistency, distributed transactions (Saga pattern), service discovery, centralized logging, distributed tracing.
Spring Cloud provides tools for distributed systems:
| Component | Purpose |
|---|---|
| Spring Cloud Config | Centralized config server |
| Netflix Eureka | Service registry/discovery |
| Spring Cloud Gateway | API gateway, routing, rate limiting |
| OpenFeign | Declarative REST client |
| Resilience4j | Circuit breaker, retry, rate limiter |
| Spring Cloud Sleuth | Distributed tracing (replaced by Micrometer Tracing) |
| Zipkin | Trace visualization |
| Spring Cloud Bus | Propagate config changes via message broker |
| Spring Cloud Stream | Message-driven microservices (Kafka/RabbitMQ) |
Circuit breaker prevents cascading failures when a dependency is down:
States:
- CLOSED: Normal operation; requests pass through
- OPEN: Too many failures; requests fail fast (no call to dependency)
- HALF-OPEN: After wait period, allow limited requests to test recovery
// Resilience4j
@CircuitBreaker(name = "orderService", fallbackMethod = "fallbackOrder")
@Retry(name = "orderService")
@TimeLimiter(name = "orderService")
public CompletableFuture<Order> getOrder(Long id) {
return CompletableFuture.supplyAsync(() -> orderClient.getOrder(id));
}
public CompletableFuture<Order> fallbackOrder(Long id, Exception ex) {
log.warn("Circuit open for order {}: {}", id, ex.getMessage());
return CompletableFuture.completedFuture(Order.empty(id));
}
resilience4j:
circuitbreaker:
instances:
orderService:
slidingWindowSize: 10
failureRateThreshold: 50
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 3
Resilience4j provides multiple resilience patterns:
- Circuit Breaker: See Q298
- Retry:
@Retry(name = "externalApi", fallbackMethod = "fallback")
public String callApi() { ... }
resilience4j.retry.instances.externalApi: maxAttempts: 3 waitDuration: 500ms enableExponentialBackoff: true exponentialBackoffMultiplier: 2
- Rate Limiter:
@RateLimiter(name = "apiGateway")
public String callGateway() { ... }
- Bulkhead: Limit concurrent calls (Thread pool or Semaphore)
@Bulkhead(name = "service", type = Bulkhead.Type.SEMAPHORE)
- Time Limiter: Timeout for async calls
- Cache: Simple result caching
Combining:
@CircuitBreaker(name = "svc", fallbackMethod = "fb")
@Retry(name = "svc")
@RateLimiter(name = "svc")
public Result callService() { ... }
// Order: RateLimiter → CircuitBreaker → Retry
A Spring Boot service is production-ready when it has:
Observability:
- [ ] Structured logging with correlation IDs (MDC)
- [ ] Metrics exposed (
/actuator/prometheusor micrometer) - [ ] Distributed tracing (Micrometer Tracing + Zipkin/Jaeger)
- [ ] Health checks for orchestration (
/actuator/health/liveness,readiness) - [ ] Alerting on error rate, latency, saturation
Resilience:
- [ ] Circuit breakers for external dependencies
- [ ] Retry with backoff
- [ ] Timeouts on all external calls (HTTP client, DB, etc.)
- [ ] Bulkheads (bounded thread pools)
- [ ] Graceful shutdown (
spring.lifecycle.timeout-per-shutdown-phase=30s)
Security:
- [ ] Authentication + authorization
- [ ] Secrets in vault/environment, not in code
- [ ] HTTPS enforced
- [ ] Input validation
- [ ] Dependency vulnerability scan (OWASP Dependency Check, Snyk)
- [ ] Actuator endpoints secured or restricted
Performance:
- [ ] Connection pool tuned (HikariCP:
maximum-pool-size) - [ ] Database indexes for common queries
- [ ] Caching for expensive/repeated operations
- [ ] Thread pool sized appropriately
- [ ] JVM heap and GC tuned
- [ ] Load test results acceptable
Deployment:
- [ ] Dockerfile with non-root user, minimal base image
- [ ] Kubernetes liveness/readiness probes configured
- [ ] Resource requests/limits set
- [ ] Rolling update strategy
- [ ]
-XX:+UseContainerSupportfor JVM container awareness - [ ] Environment-specific configuration externalized
Testing:
- [ ] Unit tests (target >80% coverage on business logic)
- [ ] Integration tests (Testcontainers for real DB)
- [ ] Contract tests (Pact or Spring Cloud Contract)
- [ ] Load test results meet SLA
Documentation:
- [ ] OpenAPI/Swagger (
springdoc-openapi) - [ ] README with runbook
- [ ] Architecture decision records (ADRs)
Ops:
- [ ] Log rotation configured
- [ ] Heap dump path set (
-XX:HeapDumpPath) - [ ] OOM action:
-XX:+ExitOnOutOfMemoryError - [ ] JFR enabled for profiling capability
- [ ] Runbook for common failure scenarios