Pollito's Opinion on Spring Boot Development 2: Best practices boilerplate
Posted on October 2, 2024 • 9 minutes • 1726 words • Other languages: Español
- Some context
- 1. Understanding the project
- 2. Create a new Spring Boot project with the help of Spring Initialzr
- 3. Essential dependencies + best practice boilerplates
- Next lecture
Some context
This is the second part of the Spring Boot Development blog series.
- The objective of the series is to be a demonstration of how to consume and create an API following Design by Contract principles .
- To achieve that, we are going to create a Java Spring Boot Microservice that handles information about users.
- You can find the code of the final result at this GitHub repo - branch feature/feignClient .
Let’s start!
1. Understanding the project
We are going to create a Java Spring Boot Microservice that handles information about users.
Here’s an explanation of its components and workflow:
Components
- Requesting Client:
- User or system making the API request to the microservice.
- LogFilter:
- Filter that intercepts every request and response to log information.
- UsersController:
- The controller layer in the Spring Boot microservice that handles HTTP endpoints (/users, /users/{id}).
- It processes the request, interacts with the service layer, and returns the response.
- UsersService:
- Service layer that contains the business logic. It communicates with other services or APIs if needed.
- UsersApiCacheService:
- Caching layer to avoid unnecessary calls to external APIs. It ensures the logic below this layer (external calls) is executed only once by utilizing cached results.
- UsersApi:
- External API that provides user data.
- GlobalControllerAdvice:
- Global exception handler. If an exception occurs at any stage in the processing of the request, this component catches it and ensures the response is appropriately formatted.
Workflow
- Incoming Request: A client sends a request to the microservice (e.g., GET /users or GET /users/{id}).
- LogFilter: The request first passes through the LogFilter, which logs information.
- Controller Processing: The request is routed to the UsersController, which invokes the appropriate method based on the endpoint.
- Service Layer: The controller delegates the business logic to the UsersService.
- Caching Layer: UsersService calls UsersApiCacheService to check if the data is already cached. If cached, it skips calling the external API.
- External API Call: If the data is not cached, UsersApiCacheService invokes UsersApi to fetch the data from the external API.
- Response Assembly: The data is passed back up through the layers to the controller, which formats and sends the response to the client.
- Exception Handling: If any exception occurs during the process, GlobalControllerAdvice intercepts it and formats the response.
2. Create a new Spring Boot project with the help of Spring Initialzr
I’ll use the integrated Spring Initializr that comes with IntelliJ IDEA 2021.3.2 (Ultimate Edition). You can get the same result by going to Spring Initialzr , following the same steps, and working with the generated zip.
- Language: Java
- Type: Maven
- You could make this work in Gradle. But for this tutorial purpose, I’ll be using Maven.
- Java: 21
- At the moment of writing this blog, Java 21 is the latest LTS in the Oracle Java SE Support Roadmap .
- Packaging: JAR
Group, Artifact, and Package name fill them corresponding to the project you are making.
At the moment of writing this blog, Spring Boot 3.3.4 is the latest stable release.
Add the dependencies:
Do a maven clean and compile, and run the main application class. You should find the Whitelabel Error Page at http://localhost:8080/ .
3. Essential dependencies + best practice boilerplates
3.1. Dependencies
Add the dependencies:
- JetBrains Java Annotations
- AspectJ Tools (Compiler)
- Micrometer Observation
- Micrometer Tracing Bridge OTel
- MapStruct Core
And the plugins:
- Apache Maven Compiler Plugin
- MapStruct Processor
- Lombok Mapstruct Binding
- fmt-maven-plugin
- Pitest Maven
- Pitest JUnit 5 Plugin
Here I leave some ready copy-paste for you. Consider double-checking the latest version.
Under the <dependencies> tag:
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>24.1.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjtools</artifactId>
<version>1.9.22.1</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-observation</artifactId>
<version>1.13.4</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
<version>1.3.4</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.6.1</version>
</dependency>
Under the <plugins> tag:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.6.1</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</dependency>
</annotationProcessorPaths>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>com.spotify.fmt</groupId>
<artifactId>fmt-maven-plugin</artifactId>
<version>2.24</version>
<executions>
<execution>
<goals>
<goal>format</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.17.0</version>
<executions>
<execution>
<id>pit-report</id>
<phase>test</phase>
<goals>
<goal>mutationCoverage</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>1.2.1</version>
</dependency>
</dependencies>
<configuration>
<targetClasses>
<param>${project.groupId}.${project.artifactId}.controller.*</param>
<param>${project.groupId}.${project.artifactId}.service.*</param>
<param>${project.groupId}.${project.artifactId}.util.*</param>
</targetClasses>
<targetTests>
<param>${project.groupId}.${project.artifactId}.*</param>
</targetTests>
</configuration>
</plugin>
3.2. Create a basic @RestController, it is going to be useful later
controller/UsersController.java
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UsersController {
}
3.3. Logs
Considering we don’t mind accidentally printing sensitive information (keys, passwords, etc.), I’ve found useful to log
- Everything that comes in.
- Everything that comes out.
To achieve that we are going to be using:
- An Aspect that logs before and after execution of public controller methods.
- A Filter interface that logs stuff that doesn’t reach the controllers.
Aspect
aspect/LogAspect.java
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component;
import java.util.Arrays;
@Aspect
@Component
@Slf4j
public class LogAspect {
@Pointcut("execution(public * dev.pollito.user_manager_backend.controller..*.*(..))") //todo: point to your controller package
public void controllerPublicMethodsPointcut() {}
@Before("controllerPublicMethodsPointcut()")
public void logBefore(@NotNull JoinPoint joinPoint) {
log.info(
"["
+ joinPoint.getSignature().toShortString()
+ "] Args: "
+ Arrays.toString(joinPoint.getArgs()));
}
@AfterReturning(pointcut = "controllerPublicMethodsPointcut()", returning = "result")
public void logAfterReturning(@NotNull JoinPoint joinPoint, Object result) {
log.info("[" + joinPoint.getSignature().toShortString() + "] Response: " + result);
}
}
In the Pointcut annotation, point to your controller package.
Filter
filter/LogFilter.java
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Enumeration;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
@Slf4j
public class LogFilter implements Filter {
@Override
public void doFilter(
ServletRequest servletRequest,
ServletResponse servletResponse,
@NotNull FilterChain filterChain)
throws IOException, ServletException {
logRequestDetails((HttpServletRequest) servletRequest);
filterChain.doFilter(servletRequest, servletResponse);
logResponseDetails((HttpServletResponse) servletResponse);
}
private void logRequestDetails(@NotNull HttpServletRequest request) {
log.info(
">>>> Method: {}; URI: {}; QueryString: {}; Headers: {}",
request.getMethod(),
request.getRequestURI(),
request.getQueryString(),
headersToString(request));
}
public String headersToString(@NotNull HttpServletRequest request) {
Enumeration<String> headerNames = request.getHeaderNames();
StringBuilder stringBuilder = new StringBuilder("{");
while (headerNames.hasMoreElements()) {
String headerName = headerNames.nextElement();
String headerValue = request.getHeader(headerName);
stringBuilder.append(headerName).append(": ").append(headerValue);
if (headerNames.hasMoreElements()) {
stringBuilder.append(", ");
}
}
stringBuilder.append("}");
return stringBuilder.toString();
}
private void logResponseDetails(@NotNull HttpServletResponse response) {
log.info("<<<< Response Status: {}", response.getStatus());
}
}
config/LogFilterConfig.java
import dev.pollito.post.filter.LogFilter; //todo: import your own filter created in the previous step
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class LogFilterConfig {
@Bean
public FilterRegistrationBean<LogFilter> loggingFilter() {
FilterRegistrationBean<LogFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new LogFilter());
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
}
3.4. Normalize errors being returned
One of the most annoying things when consuming a microservice is that the errors it returns are not consistent. At work, I have plenty of scenarios like:
service.com/users/-1 returns
{
"errorDescription": "User not found",
"cause": "BAD REQUEST"
}
but service.com/product/-1 returns
{
"message": "not found",
"error": 404
}
Consistency just flew out of the window there, and is annoying as f*ck (and don’t get me started with errors inside 200OK).
We don’t want to be that kind of guy. We are going to do proper error handling with @RestControllerAdvice and ProblemDetail , so all our errors at least they look the same.
controller/advice/GlobalControllerAdvice.java
import io.opentelemetry.api.trace.Span;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
@Slf4j
public class GlobalControllerAdvice {
@ExceptionHandler(Exception.class)
public ProblemDetail handle(@NotNull Exception e) {
return buildProblemDetail(e, HttpStatus.INTERNAL_SERVER_ERROR);
}
@NotNull
private static ProblemDetail buildProblemDetail(@NotNull Exception e, HttpStatus status) {
String exceptionSimpleName = e.getClass().getSimpleName();
log.error("{} being handled", exceptionSimpleName, e);
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, e.getLocalizedMessage());
problemDetail.setTitle(exceptionSimpleName);
problemDetail.setProperty("timestamp", DateTimeFormatter.ISO_INSTANT.format(Instant.now()));
problemDetail.setProperty("trace", Span.current().getSpanContext().getTraceId());
return problemDetail;
}
}
Now when going to http://localhost:8080/ , you won’t see the Whitelabel Error Page. Instead, you’ll find a json:
From now on, all the errors that this microservice returns have the following structure:
detail:
description: Description of the problem.
example: "No static resource ."
type: string
instance:
description: The endpoint where the problem was encountered.
example: "/"
type: string
status:
description: http status code
example: 500
type: integer
title:
description: A short headline of the problem.
example: "Internal Server Error"
type: string
timestamp:
description: ISO 8601 Date.
example: "2024-10-02T12:29:19.326053Z"
type: string
trace:
description: opentelemetry TraceID, a unique identifier.
example: "0c6a41e22fe6478cc391908406ca9b8d"
type: string
type:
description: used to point the client to documentation where it is explained clearly what happened and why.
example: "about:blank"
type: string
You can customize this object by adjusting the ProblemDetail properties.
When looking at the logs, you can find more detailed information. It goes:
- -> LogFilter
- -> LoggingAspect
- -> GlobalControllerAdvice
- -> LoggingAspect
- -> LogFilter
Notice that all the logs have associated a long UUID like string. That is made by the micrometer dependencies. Each request incoming into this microservice will have a different number, so we can differentiate what’s going on in case multiple request appears at the same time and the logs start mixing with each other.
[Optional] Customize GlobalControllerAdvice
Right now you could be thinking
but No static resource should be 404 instead of 500
to which I say, yes you’re totally right and I wish there was a way to implement that behaviour by default. But with this normalization of errors, everything is a 500 unless you explicitly say otherwise. I think the trade-off is worth it.
For making “No static resource” a 404, add in the @RestControllerAdvice class a new @ExceptionHandler(NoResourceFoundException.class) method. The final result looks like this:
controller/advice/GlobalControllerAdvice.java
import io.opentelemetry.api.trace.Span;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
@RestControllerAdvice
@Slf4j
public class GlobalControllerAdvice {
@ExceptionHandler(NoResourceFoundException.class)
public ProblemDetail handle(@NotNull NoResourceFoundException e) {
return buildProblemDetail(e, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(Exception.class)
public ProblemDetail handle(@NotNull Exception e) {
return buildProblemDetail(e, HttpStatus.INTERNAL_SERVER_ERROR);
}
@NotNull
private static ProblemDetail buildProblemDetail(@NotNull Exception e, HttpStatus status) {
String exceptionSimpleName = e.getClass().getSimpleName();
log.error("{} being handled", exceptionSimpleName, e);
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, e.getLocalizedMessage());
problemDetail.setTitle(exceptionSimpleName);
problemDetail.setProperty("timestamp", DateTimeFormatter.ISO_INSTANT.format(Instant.now()));
problemDetail.setProperty("trace", Span.current().getSpanContext().getTraceId());
return problemDetail;
}
}
Remember that in @RestControllerAdvice, the order of the functions matter. Because every whatever-exception is a child of Exception.class, if you put it at the beginning of the file, it will always match. For that reason, the method annotated with @ExceptionHandler(Exception.class) should be the last public method of the file.
Now when requesting to http://localhost:8080/ you get the new expected behaviour:
Repeat this process for any other Exception you’d like to have a non 500 default response.
Next lecture
Pollito’s Opinion on Spring Boot Development 3: Spring server interfaces