This document is designed to help new Spring Boot developers understand the basics of building applications using Spring Boot 3. It covers the structure of a sample project Customer API, explains the purpose of key annotations, and provides insights into best practices. Note: You have also to check the code example and not only this markdown file because some parts are not shown here (custom annotations, Utils class etc).
Disclaimer: This guide reflects my personal opinion and approach, based on the knowledge I have gained through my work as a developer and my studies. It is not necessarily the best or only way to do things, and as time passes, practices and tools may evolve. I encourage you to explore other perspectives and approaches as well.
Feedback and Contributions: I am always open to feedback and contributions. If you have suggestions for improvement or additional insights, please feel free to share. Together, we can make this a valuable resource for anyone learning Spring Boot 3.
- Introduction
- Project Structure Overview
- Introduction to Maven and
pom.xml
- Key Annotations in Spring Boot
- Dependency Injection in Spring Boot
- Design Patterns: RESTful API vs. MVC
- Naming Conventions
- Configuring
application.yaml
- Detailed Package Breakdown
- Helper Classes
- Testing
- Best Practices
- Enhanced Pagination Example
- Appendix: Using
openapi-generator-maven-plugin
for API Client Generation - Feedback and Contributions
Spring Boot is an extension of the Spring framework that simplifies the development of Java applications. It provides tools and conventions that allow developers to get started quickly without needing to manually configure and set up complex frameworks.
- Auto-configuration: Automatically configures your application based on the dependencies you add to your project.
- Embedded Server: You don’t need to set up an external server like Tomcat; Spring Boot applications can run with an embedded server.
- Production-Ready: Includes features like health checks, metrics, and externalized configuration, making it easy to deploy applications in a production environment.
Understanding the structure of a Spring Boot project is crucial for effective development. Below is the typical structure of a sample Spring Boot 3 project. Please note this is something I follow based on the knowledge I gained from other developers. Furthermore, some packages can be skipped in case based on your use case you do not need them.
├── config
├── controller
├── dto
├── entity
├── enums
├── exception
├── filter
├── mapper
├── repository
├── service
│ └── impl
└── utils
config
: Contains configuration classes for application-wide settings (e.g., security, open api).controller
: REST controllers handling HTTP requests, routing them to services (note: do not add logic here).dto
: Data Transfer Objects (DTOs) used for transferring data between client and server.entity
: JPA entities that represent database tables.enums
: Enumerations used across the application.exception
: Custom exceptions and a global exception handler.filter
: Request filtering and logging logic.mapper
: Classes that map entities to DTOs and vice versa. In this case I will use MapStruct for compile-time mapping.repository
: Data access layer using Spring Data JPA repositories.service
: Business logic layer, including interfaces and their implementations.utils
: Utility classes and helpers used across the application.
Maven is a powerful build automation and project management tool that is widely used in Java projects. It helps manage
project builds, dependencies, and configurations in a standardized way. Maven centralizes the project’s setup in a file
called the Project Object Model (POM), which is typically located in the pom.xml
file at the root of your project.
The Project Object Model (POM) is the core of a Maven project. It’s an XML file that defines the structure,
dependencies, and build configuration of your project. When Maven runs, it reads the pom.xml
file to determine how to
build, test, and package your application.
groupId
: Identifies your project’s group, typically following the reverse domain name pattern of your company or organization. For example, if your company’s domain isainigma100.com
, yourgroupId
might becom.ainigma100
. This convention helps to ensure uniqueness across all projects globally by using a domain that the organization owns or controls.artifactId
: The name of your project or module (e.g.,customer-api
). It represents the artifact, which is usually the output of the project, such as a JAR file.version
: The current version of your project (e.g.,0.0.1-SNAPSHOT
). It indicates the specific iteration of the project, helping in managing releases and dependencies.
Dependencies define the external libraries your project needs to function. These are specified in the <dependencies>
section of the pom.xml
and are automatically downloaded and included by Maven.
Plugins extend the functionality of Maven and are used to perform various build-related tasks, such as compiling code,
running tests, and packaging the application. They are specified in the <build>
section of the pom.xml
.
View pom file
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.ainigma100</groupId>
<artifactId>customer-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>customer-api</name>
<description>customer-api</description>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<springdoc-openapi-starter-webmvc-ui.version>2.6.0</springdoc-openapi-starter-webmvc-ui.version>
<org.mapstruct.version>1.6.2</org.mapstruct.version>
<lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc-openapi-starter-webmvc-ui.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruct-binding.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
This pom.xml
includes several important dependencies:
- Spring Boot Starter Web: Provides the necessary components to build a web application, including an embedded Tomcat server.
- Spring Boot Starter Data JPA: Simplifies database interactions by integrating Spring Data JPA for database operations.
- Spring Boot Starter Actuator: Adds production-ready features such as monitoring and metrics.
- Spring Boot Starter Validation: Facilitates data validation using Hibernate Validator. It allows you to use some annotations to validate an object.
- H2 Database: A lightweight in-memory database often used for testing and development.
- Lombok: Reduces boilerplate code by generating getters, setters, constructors, and other methods at compile time.
- MapStruct: A code generator that simplifies the mapping between Java beans.
- SpringDoc OpenAPI: Integrates Swagger/OpenAPI support into Spring Boot applications for API documentation.
- Spring Boot Starter Test: Provides testing libraries like JUnit, Mockito, and Spring TestContext Framework for unit and integration testing.
As your project evolves, you might need additional libraries or tools. You can add more dependencies by searching for
them in the Maven Central Repository and including them in the <dependencies>
section of your pom.xml
.
In the <build>
section of the pom.xml
, we configure the Maven Compiler Plugin with additional settings for Lombok
and MapStruct:
-
Lombok: Since Lombok generates code during the compilation phase (e.g., constructors, getters, setters), it requires an annotation processor. The plugin configuration ensures that Lombok's annotation processor is included, allowing Lombok to function correctly during compilation.
-
MapStruct: MapStruct is a code generator that automatically creates mappers for converting between different Java beans. Like Lombok, MapStruct requires an annotation processor to generate the necessary code during compilation. The plugin configuration includes the MapStruct processor, ensuring that the mappings are generated correctly.
-
Lombok-MapStruct Binding: MapStruct and Lombok both operate during the annotation processing phase, but they sometimes need to coordinate to avoid conflicts. The
lombok-mapstruct-binding
dependency ensures that Lombok's generated code is compatible with MapStruct, allowing both tools to work together seamlessly. This binding is configured in theannotationProcessorPaths
section of the Maven Compiler Plugin, ensuring that the processors are run in the correct order.
By configuring these plugins, we ensure that Lombok, MapStruct, and their integration work seamlessly during the build process, reducing manual coding effort and improving efficiency.
In this section, we’ll walk through the key annotations used across the various classes in the example project, explaining their purpose and best practices. These annotations are essential for managing dependencies, handling HTTP requests, mapping database entities, and more. Note that we will cover annotations specific to Spring Boot separately from those provided by Lombok.
These annotations are core to Spring Boot and are used throughout the application to define its structure and behavior.
-
@Autowired
: This annotation is used for automatic dependency injection. It can be applied to constructors, methods, fields, and even parameters to inject dependencies wherever they are needed, not just in service classes. When used on a constructor, it can be omitted if the class has only one constructor. -
@Component
,@Service
,@Repository
,@RestController
: These annotations are specializations of@Component
and are used to define Spring beans. They indicate that a class is a Spring-managed component and can be automatically detected during component scanning.@RestController
is a specialization of@Controller
used for handling web requests and returning data directly as a response (usually JSON), combining the behavior of@Controller
and@ResponseBody
.
@Entity
: Marks the class as a JPA entity, meaning it is mapped to a database table.@Table(name = "customers")
: Specifies the table name in the database that this entity maps to.@Id
: Denotes the primary key of the entity.@GeneratedValue(strategy = GenerationType.IDENTITY)
: Specifies the primary key generation strategy.@Column
: Marks a field as a column in the database. It can include attributes likenullable
,unique
, andlength
.@CreationTimestamp
and@UpdateTimestamp
: Automatically manage the creation and update timestamps of the entity.
-
@RestController
: Combines@Controller
and@ResponseBody
, used to handle HTTP requests and return responses directly, usually as JSON. It is the preferred annotation for RESTful web services. -
@RequestMapping("/api/v1/customers")
: Maps HTTP requests to specific methods in the controller, providing a base path for all endpoints within the controller. -
@GetMapping
,@PostMapping
,@PutMapping
,@DeleteMapping
: These annotations map HTTP GET, POST, PUT, and DELETE requests to specific methods in the controller, respectively. -
@Valid
: Used to trigger validation of the request body or path variables based on constraints defined in the DTO. -
@RequestBody
: Maps the HTTP request body to a Java object, commonly used in POST and PUT methods. -
@PathVariable
: Binds a method parameter to a URI template variable, allowing extraction of values from the URL. -
@RequestParam
: Binds a method parameter to a query parameter in the URL, useful for passing optional or required parameters to an endpoint.
@Configuration
: Indicates that the class contains Spring bean definitions and configuration settings.@Bean
: Marks a method as a bean producer in Spring’s application context. This method’s return value is registered as a Spring bean.@Value
: This annotation is used to pull configuration values from your properties file and inject them directly into your code.
@EventListener
: This annotation is used to mark a method as an event listener, which listens for specific events within the Spring application lifecycle. For example,@EventListener(ApplicationReadyEvent.class)
triggers when the application is ready to service requests.
Lombok is a Java library that helps reduce boilerplate code by automatically generating common code like constructors, getters, setters, etc. It is not part of Spring Boot but is often used alongside it for convenience.
-
@Slf4j
: Creates aLogger
instance in the class, allowing for easy logging without manually defining a logger. -
@AllArgsConstructor
,@NoArgsConstructor
, and@RequiredArgsConstructor
:@AllArgsConstructor
: Generates a constructor with parameters for all fields in the class.@NoArgsConstructor
: Generates a no-argument constructor.@RequiredArgsConstructor
: Generates a constructor for all final fields and any fields marked as@NonNull
.
-
@Data
: Generates getters, setters,equals()
,hashCode()
,toString()
, and other utility methods. While it is suitable for DTOs and simple data carrier classes, it is generally not recommended for JPA entities due to potential performance issues and complications withequals()
andhashCode()
methods. -
@Builder
: Implements the builder pattern, making it easy to create immutable objects with only the required fields set.
There are many more annotations in Spring Boot that you might encounter as your application grows or as you write tests (unit tests, integration tests, etc.). Each layer of the application (controller, service, repository, etc.) and each use case (security, data validation, testing) has specific annotations that help to streamline development and improve code quality. This section covers the key annotations used in this project, providing a solid foundation for understanding how they work together in a Spring Boot application.
Dependency Injection (DI) is a core concept in Spring that allows your classes to be loosely coupled. This means that instead of your classes creating their dependencies, Spring will provide the dependencies they need. This makes your code easier to manage, test, and maintain.
- Simplifies Code: You don’t need to create objects manually.
- Easier Testing: You can easily swap out dependencies with mock objects during testing.
- Loose Coupling: Your classes depend on abstractions (interfaces) rather than concrete implementations.
Constructor injection is the preferred way to inject dependencies in Spring Boot. This means that you pass the dependencies into a class through its constructor. This makes your classes more straightforward and ensures all necessary dependencies are available when the object is created.
Here’s how it looks:
@Service
public class CustomerService {
private final CustomerRepository customerRepository;
// Constructor Injection
public CustomerService(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
}
In the example above, CustomerService depends on CustomerRepository. Spring automatically provides CustomerRepository when creating a CustomerService instance.
In this project, I am using Lombok annotations to reduce boilerplate code. With Lombok, you can avoid writing constructors manually by using the @RequiredArgsConstructor annotation. This automatically creates a constructor for all final fields and autowires the dependencies.
@Service
@RequiredArgsConstructor
public class CustomerService {
private final CustomerRepository customerRepository;
// Lombok automatically creates the constructor for you!
}
The @Autowired
annotation tells Spring to automatically inject the required dependencies. In constructor injection, if
you have only one constructor, Spring will automatically inject dependencies without needing @Autowired.
If you are not using Lombok, you can still use constructor injection like this:
@Service
public class CustomerService {
private final CustomerRepository customerRepository;
@Autowired // Optional with a single constructor
public CustomerService(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
}
- Clearer Dependencies: It’s obvious which dependencies your class needs.
- Immutable Fields: Dependencies can be marked as
final
, ensuring they aren’t changed after they’re set. - Easier to Test: You can easily provide mock dependencies when testing.
Note: Please search online for more details and try to understand this topic because it is important.
When building applications with Spring Boot, it's essential to understand the different design patterns that can be used to structure your application. The two most common patterns are RESTful API and Model-View-Controller (MVC). Each serves a different purpose and is chosen based on the needs of the application.
- Pattern Name: RESTful API or Service-Oriented Architecture (SOA)
- Annotation:
@RestController
- Purpose: The RESTful API pattern is designed to expose data and services over HTTP. In this architecture, the server does not concern itself with the presentation layer (UI). Instead, it focuses on delivering data, usually in JSON or XML format, which is consumed by a client (like a frontend framework such as Angular, React, or Vue.js).
- Use Case: This approach is ideal for applications where the frontend is developed separately from the backend. It allows for flexibility, as the frontend can be updated independently of the backend, and the same backend can serve multiple clients (web, mobile, etc.).
- Pattern Name: Model-View-Controller (MVC)
- Annotation:
@Controller
- Purpose: The MVC pattern is a traditional approach where the server is responsible for both processing data and
rendering the user interface. The
@Controller
annotation is used to handle HTTP requests and return views (typically HTML) that are rendered on the server side using templating engines like Thymeleaf. - Use Case: MVC is suitable for monolithic applications where the server-side code manages both the business logic and the presentation logic. It’s often used in applications where the frontend is tightly coupled with the backend, and there's less need for a separate API layer.
-
Separation of Concerns:
- RESTful API: Decouples the backend from the frontend. The server provides data, while the client handles the presentation.
- MVC: The server manages both data processing and presentation, offering a tightly integrated solution.
-
Flexibility:
- RESTful API: Offers greater flexibility as the backend can serve multiple types of clients, and the frontend can be updated independently.
- MVC: Less flexible because the frontend and backend are tightly coupled, making it harder to update one without impacting the other.
-
Scalability:
- RESTful API: Easier to scale horizontally as the server's responsibilities are limited to providing data.
- MVC: Can be more challenging to scale because the server handles both the data and the presentation logic.
-
Development Speed:
- RESTful API: Faster for teams working on large applications with separate frontend and backend teams.
- MVC: Faster for small to medium-sized projects where one team handles both frontend and backend, and where integrating the two layers is straightforward.
The choice between RESTful API and MVC depends on your project requirements:
-
Use RESTful API if:
- Your frontend is built with modern JavaScript frameworks.
- You need to serve multiple types of clients.
- You prefer a decoupled architecture that allows for more flexibility and easier maintenance.
-
Use MVC if:
- Your application is relatively simple, and the frontend is tightly coupled with the backend.
- You want to leverage server-side rendering for better SEO or faster initial page loads.
- You’re building a monolithic application where integrating UI and backend logic is straightforward and beneficial.
Consistent naming conventions in your codebase and API design make your project easier to navigate, maintain, and scale. Below are some guidelines for naming conventions in a Spring Boot project focused on a customer-related API.
- Lowercase and Singular: Package names should be in lowercase and singular. This enhances readability and
consistency.
- Example:
com.example.customerapi.controller
,com.example.customerapi.service
- Example:
- No Special Characters: Avoid using special characters or underscores in package names. Stick to simple,
descriptive names.
- Example:
com.example.customerapi.repository
(notcom.example_customerapi.repository
)
- Example:
- PascalCase: Class names should follow PascalCase, where each word starts with an uppercase letter. This is a
widely accepted convention in Java.
- Example:
CustomerService
,CustomerController
,CustomerRepository
- Example:
- Meaningful Names: Choose descriptive names that clearly indicate the purpose of the class.
- Example:
CustomerNotificationService
instead ofNotificationHelper
- Example:
- Singular Form: Entity classes should be named in the singular form to represent a single instance of the entity.
- Example:
Customer
,Address
,Order
- Example:
- Mapped to Plural Table Names: Entities often map to plural table names in the database.
- Example:
Customer
class maps tocustomers
table,Order
class maps toorders
table
- Example:
-
Plural Nouns: Use plural nouns for API endpoints to represent collections of resources.
- Example:
/customers
,/orders
,/addresses
- Example:
-
Lowercase with Hyphens: Endpoint paths should be lowercase, with hyphens separating words for readability.
- Example:
/customers/{customerId}/orders
,/orders/{orderId}/items
,/customers/{customerId}/addresses
- Example:
-
Avoid Verbs in URIs: Use nouns to represent resources. The HTTP method (GET, POST, PUT, DELETE) should define the action, not the URI.
- Bad Examples:
/getCustomers
,/createOrder
,/deleteCustomer
- Good Examples:
/customers
(GET),/orders
(POST),/customers/{id}
(DELETE)
- Bad Examples:
-
Use Forward Slashes (/) for Hierarchy: Forward slashes are used to indicate a hierarchical relationship between resources.
- Example:
/customers/{customerId}/orders
,/customers/{customerId}/addresses
- Example:
-
Do Not Use Trailing Slashes: Avoid trailing slashes at the end of the URI.
- Bad Example:
/customers/
- Good Example:
/customers
- Bad Example:
-
Use Query Parameters for Filtering: When filtering collections, use query parameters instead of creating new endpoints. In some cases you may see filtering and sorting information provided as a payload inside a request body.
- Example:
/customers?status=active
,/orders?customerId=123&status=pending
- Example:
The application.yaml
file is an essential configuration file in a Spring Boot project. It allows you to manage your
application's settings in a clear and structured manner. YAML is preferred over properties files in many cases because
it is easier to read and supports hierarchical data.
When you create a new Spring Boot project, the default configuration file is named application.properties
. However,
many developers choose to use application.yaml
instead, as it offers a more concise and readable format, especially
when dealing with complex configurations.
The main application.yaml
file typically includes common settings that apply to all environments, such as server
configuration, application name, and basic Spring settings.
In addition to the main application.yaml
file, you can create environment-specific YAML files, such as
application-dev.yaml
for development, application-uat.yaml
for user acceptance testing, and application-prod.yaml
for production. These files contains settings specific to each environment, allowing you to easily switch between
configurations without changing your code.
You can activate a specific profile in several ways:
- In
application.yaml
: You can specify the active profile directly in theapplication.yaml
file using thespring.profiles.active
property. This is useful for setting a default profile that will be used unless another is specified at runtime. - At Runtime: You can also activate a profile at runtime by passing the
spring.profiles.active
property as a command-line argument or setting it as an environment variable.
- Keep It Simple: Store common settings in the main
application.yaml
and use environment-specific files for configuration differences. - Use Profiles: Profiles help manage different environments like development, testing, and production by allowing you to specify environment-specific configurations.
- YAML Advantages: The YAML format is preferred for its readability and ability to handle complex, hierarchical settings more gracefully than traditional properties files.
In this section, I provide two YAML configuration files to demonstrate some of the settings I use for a Spring Boot project. These are just examples, and many other configurations are available depending on your project's needs. As I continue to develop this project, I may add more configurations.
This file contains general settings that apply to all environments. Here’s a breakdown of the configurations used:
server.port
: Specifies the port on which the application will run. In this case, it’s set to8088
. If you do not specify the port, the app will start on adefault port
which is8080
.server.servlet.context-path
: Defines the base URL path for the application. Here, it’s dynamically set to the artifact ID of the project.server.shutdown.graceful
: Enables graceful shutdown, ensuring that the application completes ongoing requests before shutting down.spring.profiles.active
: Indicates the active profile to be used. In this case, the development profile (dev
) is active.spring.application.name
: Sets the application name, dynamically pulled from the project configuration.spring.lifecycle.timeout-per-shutdown-phase
: Configures the timeout for each shutdown phase, here set to25 seconds
.spring.output.ansi.enabled
: Controls ANSI output in the console, set toalways
to ensure colored output.springdoc.swagger-ui.path
: Sets the path for accessing the Swagger UI, useful for API documentation.springdoc.title
andspringdoc.version
: Define the title and version of the API documentation, again dynamically set based on project properties.openapi.output.file
: Specify the file name of the swagger file that will be generated byOpenApiConfig
class on start-up. It will be the documentation of the application.
server:
port: 8088
servlet:
context-path: '/@project.name@'
shutdown: graceful
spring:
profiles:
active: dev # Specify the active profile
application:
name: '@project.name@'
lifecycle:
timeout-per-shutdown-phase: 25s
output:
ansi:
enabled: always
springdoc:
swagger-ui:
path: /ui
title: 'Customer API'
version: '@springdoc-openapi-starter-webmvc-ui.version@'
openapi:
output:
file: '[email protected]@.json'
This file contains settings specific to the development environment. Here's what each setting does:
datasource.url
: Configures the JDBC URL for the H2 database. In this example, the database is stored in a local file (./data/customer-db
) withAUTO_SERVER=true
to allow remote connections.datasource.username
anddatasource.password
: Set the database username and password. The default username (sa
) is used with no password.datasource.driver-class-name
: Specifies the JDBC driver class for the H2 database.jpa.show-sql
: Enables logging of SQL statements generated by Hibernate.jpa.properties.hibernate.format_sql
: Formats SQL output to be more readable.jpa.properties.hibernate.dialect
: Specifies the Hibernate dialect to use, which is set toH2Dialect
for compatibility with the H2 database.jpa.generate-ddl
: Automatically generates database schema (DDL) from JPA entities.jpa.hibernate.ddl-auto
: Controls the behavior of schema generation at runtime, withupdate
allowing for incremental updates to the schema.
- These configurations are just examples based on what I use in this project. There are many other configurations you can apply depending on your project's needs.
- As the project evolves, I may add more configurations to enhance functionality or address specific needs.
- Feel free to explore additional configurations and adjust these examples to fit your project requirements.
Feedback and Contributions: If you have suggestions or improvements, please share them. Collaboration is key to refining this guide and making it a valuable resource for all developers.
datasource:
url: jdbc:h2:file:./data/notification-manager-db;AUTO_SERVER=true
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.H2Dialect
generate-ddl: true
hibernate:
ddl-auto: update
-
Purpose: The entity layer represents the database tables in the form of JPA entities. Each entity class typically maps to a single table in the database.
-
Package:
entity
-
Example Classes:
Customer.java
: Represents thecustomers
table.
-
Key Annotations:
@Entity
: Marks a class as a JPA entity.@Table(name = "table_name")
: Specifies the name of the table in the database.@Id
: Indicates the primary key of the entity.@GeneratedValue(strategy = GenerationType.IDENTITY)
: Defines the strategy for primary key generation.
View Customer code
package com.ainigma100.customerapi.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.hibernate.proxy.HibernateProxy;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Objects;
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String firstName;
@Column(nullable = false)
private String lastName;
@Column(nullable = false, unique = true)
private String email;
private String phoneNumber;
private LocalDate dateOfBirth;
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdDate;
@UpdateTimestamp
private LocalDateTime updatedDate;
@Override
public final boolean equals(Object o) {
if (this == o) return true;
if (o == null) return false;
Class<?> oEffectiveClass = o instanceof HibernateProxy hibernateProxy ? hibernateProxy.getHibernateLazyInitializer().getPersistentClass() : o.getClass();
Class<?> thisEffectiveClass = this instanceof HibernateProxy hibernateProxy ? hibernateProxy.getHibernateLazyInitializer().getPersistentClass() : this.getClass();
if (thisEffectiveClass != oEffectiveClass) return false;
Customer customer = (Customer) o;
return getId() != null && Objects.equals(getId(), customer.getId());
}
@Override
public final int hashCode() {
return this instanceof HibernateProxy hibernateProxy ? hibernateProxy.getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode();
}
}
While @Data
is a convenient annotation provided by Lombok that generates getters, setters, toString()
, equals()
,
and hashCode()
methods, it is not recommended for use with JPA entities (I got this warning from JPA Buddy plugin).
Using @Data
in JPA entities can lead to severe performance and memory consumption issues. Additionally, @Data
generates equals()
and hashCode()
methods that might not be suitable for entities, particularly in cases involving
entity relationships and lazy loading.
-
equals()
: This method determines whether two instances are considered equal. For JPA entities, this typically means comparing the primary key (ID). Properly implementingequals()
ensures that the entity behaves correctly when compared in collections or when managed by the persistence context. -
hashCode()
: This method provides a hash code for the entity, which is essential for its use in hash-based collections likeHashSet
orHashMap
. Properly implementinghashCode()
ensures consistency and correctness when the entity is stored or retrieved from such collections.
For more details on why these methods are important and best practices for implementing them, you can find
resources and discussions online.
You can also specify the name of the column in the database using the @Column(name = "column_name")
annotation.
This is useful when the field name in the entity class differs from the column name in the database table.
Example:
@Column(name = "first_name", nullable = false)
private String firstName;
Note: If you do not specify the column name and the property is in camelCase, the column name will automatically be
converted to snake_case in most databases. For example, if your entity has a field named firstName
, it will be mapped
to a column named first_name
by default.
-
Purpose: The repository layer handles CRUD operations for entities using Spring Data JPA, allowing you to interact with the database without writing SQL.
-
Package:
repository
-
Example Class:
CustomerRepository.java
: Manages CRUD operations for theCustomer
entity.
-
Key Concepts:
extends JpaRepository<Customer, Long>
: By extendingJpaRepository
, Spring Boot automatically configures a repository bean for you. This bean is a proxy implementation ofSimpleJpaRepository
, which provides all the necessary CRUD operations and query methods for theCustomer
entity. No need for@Repository
or@Component
annotations—Spring handles the configuration.
View CustomerRepository code
package com.ainigma100.customerapi.repository;
import com.ainigma100.customerapi.entity.Customer;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CustomerRepository extends JpaRepository<Customer, Long> {
Customer findByEmail(String email);
}
Spring Data JPA offers several ways to write queries in your repository interfaces, including:
You can create simple queries by following naming conventions.
Example:
List<Customer> findByLastName(String lastName);
For more complex queries, you can use the @Query annotation.
Example:
import org.springframework.data.repository.query.Param;
@Query("SELECT c FROM Customer c WHERE c.email = :email")
Customer findByEmail(@Param("email") String email);
You can write native SQL queries using the @Query annotation and providing an extra parameter nativeQuery = true
.
Example:
import org.springframework.data.repository.query.Param;
@Query(value = "SELECT * FROM customers WHERE status = :status", nativeQuery = true)
List<Customer> findByStatus(@Param("status") String status);
-
Advantages: Native queries can be useful when you need to leverage database-specific features, optimize performance, or execute complex SQL that might not be easily expressed in JPQL (Java Persistence Query Language).
-
Disadvantages: However, native queries can reduce the portability of your application across different database systems since they are tied to a specific SQL dialect. They also bypass some of the safety checks and optimizations provided by JPQL, such as automatic mapping of query results to entities.
-
Best Practice: Prefer using JPQL or derived query methods for most queries to maintain portability and leverage JPA's features. Use native queries only when necessary for performance optimization or when dealing with complex queries that JPQL cannot handle efficiently.
DTOs (Data Transfer Objects) are used to transfer data between the service layer and the controller layer. They are simple POJOs (Plain Old Java Objects) that contain only the necessary data and are often used to decouple the internal entity models from the external API contract.
-
DTO Usage:
- DTOs ensure that only relevant information is exposed to the client. They help in shaping the data according to the needs of the client while hiding unnecessary internal details.
- DTOs can also include validation annotations, ensuring that the data received or sent is valid according to business rules.
-
MapStruct for Mapping:
- Automatic Mapping: MapStruct automatically maps fields with the same name between entity classes and DTOs. For
fields with different names, you can use the
@Mapping
annotation. - Custom and Complex Mappings: Allows custom mappings for complex scenarios, including nested objects and expression-based mappings.
- Performance: MapStruct is efficient, generating simple, plain Java code for mappings without using reflection, making it faster than many other frameworks.
- Null Handling and Collection Mapping: Offers control over how null values are handled and supports mapping between collections, such as lists of entities to lists of DTOs.
- Flexible Integration: Easily integrates with Spring or other dependency injection frameworks by customizing the component model.
- Automatic Mapping: MapStruct automatically maps fields with the same name between entity classes and DTOs. For
fields with different names, you can use the
Note: You can find more information on MapStruct online.
Below is an example of a DTO and a corresponding MapStruct mapper interface:
View CustomerDTO code
package com.ainigma100.customerapi.dto;
import lombok.*;
import java.time.LocalDate;
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class CustomerDTO {
private Long id;
private String firstName;
private String lastName;
private String email;
private String phoneNumber;
private LocalDate dateOfBirth;
}
View CustomerMapper code
package com.ainigma100.customerapi.mapper;
import com.ainigma100.customerapi.dto.CustomerDTO;
import com.ainigma100.customerapi.entity.Customer;
import org.mapstruct.Mapper;
import java.util.List;
@Mapper(componentModel = "spring")
public interface CustomerMapper {
Customer toCustomer(CustomerDTO customerDTO);
CustomerDTO toCustomerDTO(Customer customer);
List<Customer> toCustomerList(List<CustomerDTO> customerDTOList);
List<CustomerDTO> toCustomerDTOList(List<Customer> customerList);
}
The service layer in a Spring Boot application contains the business logic of the application. It acts as an intermediary between the controller layer (handling HTTP requests) and the repository layer (interacting with the database).
-
Interface and Implementation: It's a good practice to define a service interface and then provide its implementation. This approach promotes loose coupling and makes your code more modular and easier to test. The interface defines the contract for the service, while the implementation class contains the actual business logic.
Example:
CustomerService
: Interface that defines methods for customer-related operations.CustomerServiceImpl
: Implementation class that provides the logic for methods like retrieving customers, updating customer details, etc.
-
Returning DTOs: The service layer should not return entities directly. Instead, it should return Data Transfer Objects (DTOs). DTOs are simple objects that carry data between layers. They are particularly useful for exposing only the necessary data to the client and for avoiding exposing the internal structure of your entities.
View CustomerService code
package com.ainigma100.customerapi.service;
import com.ainigma100.customerapi.dto.CustomerDTO;
import com.ainigma100.customerapi.dto.CustomerEmailUpdateDTO;
import com.ainigma100.customerapi.dto.CustomerSearchCriteriaDTO;
import org.springframework.data.domain.Page;
public interface CustomerService {
CustomerDTO createCustomer(CustomerDTO customerDTO);
CustomerDTO getCustomerById(Long id);
CustomerDTO updateCustomer(Long id, CustomerDTO customerDTO);
CustomerDTO updateCustomerEmail(Long id, CustomerEmailUpdateDTO emailUpdateDTO);
void deleteCustomer(Long id);
}
View CustomerServiceImpl code
package com.ainigma100.customerapi.service.impl;
import com.ainigma100.customerapi.dto.CustomerDTO;
import com.ainigma100.customerapi.dto.CustomerEmailUpdateDTO;
import com.ainigma100.customerapi.entity.Customer;
import com.ainigma100.customerapi.exception.ResourceAlreadyExistException;
import com.ainigma100.customerapi.exception.ResourceNotFoundException;
import com.ainigma100.customerapi.mapper.CustomerMapper;
import com.ainigma100.customerapi.repository.CustomerRepository;
import com.ainigma100.customerapi.service.CustomerService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@RequiredArgsConstructor
@Service
public class CustomerServiceImpl implements CustomerService {
private final CustomerRepository customerRepository;
private final CustomerMapper customerMapper;
@Override
public CustomerDTO createCustomer(CustomerDTO customerDTO) {
customerRepository.findByEmail(customerDTO.getEmail())
.ifPresent(customer -> {
throw new ResourceAlreadyExistException("Customer", "email", customerDTO.getEmail());
});
Customer recordToBeSaved = customerMapper.customerDTOToCustomer(customerDTO);
Customer savedRecord = customerRepository.save(recordToBeSaved);
return customerMapper.customerToCustomerDTO(savedRecord);
}
@Override
public CustomerDTO getCustomerById(Long id) {
Customer recordFromDB = customerRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Customer", "id", id));
return customerMapper.toCustomerDTO(recordFromDB);
}
@Override
public CustomerDTO updateCustomer(Long id, CustomerDTO customerDTO) {
Customer recordFromDB = customerRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Customer", "id", id));
// just to be safe that the object does not have another id
customerDTO.setId(recordFromDB.getId());
Customer recordToBeSaved = customerMapper.toCustomer(customerDTO);
Customer savedRecord = customerRepository.save(recordToBeSaved);
return customerMapper.toCustomerDTO(savedRecord);
}
@Override
public CustomerDTO updateCustomerEmail(Long id, CustomerEmailUpdateDTO emailUpdateDTO) {
Customer recordFromDB = customerRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Customer", "id", id));
recordFromDB.setEmail(emailUpdateDTO.getEmail());
Customer savedRecord = customerRepository.save(recordFromDB);
return customerMapper.customerToCustomerDTO(savedRecord);
}
@Override
public void deleteCustomer(Long id) {
Customer recordFromDB = customerRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Customer", "id", id));
customerRepository.delete(recordFromDB);
}
}
The controller layer in a Spring Boot application handles incoming HTTP requests and sends responses back to the client. It acts as the entry point for the client, interacting with the service layer to process business logic and return the appropriate data. Note: Do not add business logic in this class.
- Using Wrapper Objects:
-
It's a best practice to return a wrapper object from the controller rather than returning entities directly. A wrapper object can contain a DTO, along with metadata such as status codes, messages, or other relevant information.
-
Advantages:
- Encapsulation: The wrapper object encapsulates the DTO and provides a consistent response format, which can be useful for clients to process responses reliably.
- Security: By using DTOs inside wrapper objects, you avoid exposing the internal structure of your entities directly to the client. This helps in protecting sensitive information and reducing the risk of exposing unintended data.
- Flexibility: Wrapper objects allow you to include additional information, such as error messages or pagination details, making your API responses more informative and easier to handle on the client side.
-
Example:
APIResponse<CustomerDTO>
: A wrapper object that contains theCustomerDTO
and additional metadata like status and messages.
-
Wrapper Class for API Responses:
APIResponse<T>
:-
The
APIResponse<T>
class is a generic wrapper that can be used across different controllers in your application. It encapsulates the response data and adds useful metadata like status and error messages. -
Key Attributes:
status
: A string representing the status of the response (e.g., "SUCCESS" or "FAILED").errors
: A list ofErrorDTO
objects that contain error details when a request fails.results
: The actual data (DTO) being returned by the API.
-
Example:
@Data @AllArgsConstructor @NoArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) @Builder public class APIResponse<T> { private String status; private List<ErrorDTO> errors; private T results; }
-
-
This structured approach ensures that your application is well-organized, with clear separation of concerns between different layers. It also makes your API more robust, secure, and easier to maintain.
-
@GetMapping
:- Purpose: Maps HTTP GET requests to a specific handler method. It is typically used to retrieve data from the server.
- Usage: Do not include a request body in GET requests. Use path variables or query parameters to pass data to the server.
- Example:
@GetMapping("/{id}") public ResponseEntity<APIResponse<CustomerDTO>> getCustomerById(@PathVariable("id") Long id) { // You will have your implementation }
-
@PostMapping
:- Purpose: Maps HTTP POST requests to a specific handler method. It is used to create new resources on the server.
- Usage: Use
@RequestBody
to pass data in the request body when creating a new resource. - Example:
@PostMapping public ResponseEntity<APIResponse<CustomerDTO>> createCustomer(@Valid @RequestBody CustomerRequestDTO customerRequestDTO) { // You will have your implementation }
-
@PutMapping
:- Purpose: Maps HTTP PUT requests to a specific handler method. It is used to update an existing resource on the server.
- Usage: Use
@RequestBody
to pass updated data in the request body. PUT typically replaces the entire resource. - Example:
@PutMapping("/{id}") public ResponseEntity<APIResponse<CustomerDTO>> updateCustomer(@PathVariable("id") Long id, @Valid @RequestBody CustomerRequestDTO customerRequestDTO) { // You will have your implementation }
-
@PatchMapping
:- Purpose: Maps HTTP PATCH requests to a specific handler method. It is used to apply partial updates to a resource.
- Usage: Use
@RequestBody
to pass only the fields that need to be updated. PATCH is useful when you want to modify only certain attributes of the resource without affecting the rest. - Example:
@PatchMapping("/{id}") public ResponseEntity<APIResponse<CustomerDTO>> partiallyUpdateCustomer(@PathVariable("id") Long id, @RequestBody CustomerEmailUpdateDTO customerEmailUpdateDTO) { // You will have your implementation }
-
@DeleteMapping
:- Purpose: Maps HTTP DELETE requests to a specific handler method. It is used to delete a resource from the server.
- Usage: Typically does not require a request body. The resource to be deleted is usually specified in the path.
- Example:
@DeleteMapping("/{id}") public ResponseEntity<APIResponse<String>> deleteCustomer(@PathVariable("id") Long id) { // You will have your implementation }
-
Difference Between
PUT
andPATCH
:- PUT: Replaces the entire resource with the new data provided. If some fields are not provided, they will be overwritten with null or default values.
- PATCH: Applies partial updates to a resource. Only the fields provided in the request body will be updated, leaving the other fields unchanged.
View CustomerController code
package com.ainigma100.customerapi.controller;
import com.ainigma100.customerapi.dto.*;
import com.ainigma100.customerapi.enums.Status;
import com.ainigma100.customerapi.mapper.CustomerMapper;
import com.ainigma100.customerapi.service.CustomerService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RequestMapping("/api/v1/customers")
@RestController
public class CustomerController {
private final CustomerService customerService;
private final CustomerMapper customerMapper;
@Operation(summary = "Add a new customer")
@PostMapping
public ResponseEntity<APIResponse<CustomerDTO>> createCustomer(
@Valid @RequestBody CustomerRequestDTO customerRequestDTO) {
CustomerDTO customerDTO = customerMapper.customerRequestDTOToCustomerDTO(customerRequestDTO);
CustomerDTO result = customerService.createCustomer(customerDTO);
// Builder Design pattern
APIResponse<CustomerDTO> response = APIResponse
.<CustomerDTO>builder()
.status(Status.SUCCESS.getValue())
.results(result)
.build();
return new ResponseEntity<>(response, HttpStatus.CREATED);
}
@Operation(summary = "Find customer by ID",
description = "Returns a single customer")
@GetMapping("/{id}")
public ResponseEntity<APIResponse<CustomerDTO>> getCustomerById(@PathVariable("id") Long id) {
CustomerDTO result = customerService.getCustomerById(id);
// Builder Design pattern
APIResponse<CustomerDTO> responseDTO = APIResponse
.<CustomerDTO>builder()
.status(Status.SUCCESS.getValue())
.results(result)
.build();
return new ResponseEntity<>(responseDTO, HttpStatus.OK);
}
@Operation(summary = "Update an existing customer")
@PutMapping("/{id}")
public ResponseEntity<APIResponse<CustomerDTO>> updateCustomer(
@PathVariable("id") Long id,
@Valid @RequestBody CustomerRequestDTO customerRequestDTO) {
CustomerDTO customerDTO = customerMapper.customerRequestDTOToCustomerDTO(customerRequestDTO);
CustomerDTO result = customerService.updateCustomer(id, customerDTO);
// Builder Design pattern
APIResponse<CustomerDTO> responseDTO = APIResponse
.<CustomerDTO>builder()
.status(Status.SUCCESS.getValue())
.results(result)
.build();
return new ResponseEntity<>(responseDTO, HttpStatus.OK);
}
@Operation(summary = "Partially update a customer's email")
@PatchMapping("/{id}/email")
public ResponseEntity<APIResponse<CustomerDTO>> updateCustomerEmail(
@PathVariable("id") Long id,
@Valid @RequestBody CustomerEmailUpdateDTO emailUpdateDTO) {
CustomerDTO result = customerService.updateCustomerEmail(id, emailUpdateDTO);
// Builder Design pattern
APIResponse<CustomerDTO> response = APIResponse
.<CustomerDTO>builder()
.status(Status.SUCCESS.getValue())
.results(result)
.build();
return new ResponseEntity<>(response, HttpStatus.OK);
}
@Operation(summary = "Delete a customer by ID")
@DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteCustomer(@PathVariable("id") Long id) {
customerService.deleteCustomer(id);
String result = "Customer deleted successfully";
// Builder Design pattern
APIResponse<String> responseDTO = APIResponse
.<String>builder()
.status(Status.SUCCESS.getValue())
.results(result)
.build();
return new ResponseEntity<>(responseDTO, HttpStatus.OK);
}
}
In a Spring Boot application, it's important to handle exceptions in a way that provides meaningful feedback to the
client while maintaining a clean and maintainable codebase. In addition, you have to be sure not to expose sensitive
data.
The GlobalExceptionHandler
class in this project serves this purpose by centralizing exception handling and ensuring
consistent error responses across the entire application.
The GlobalExceptionHandler
class, annotated with @ControllerAdvice
, intercepts exceptions thrown by any controller
in the application. This class defines several @ExceptionHandler
methods to handle specific types of exceptions,
ensuring that the application responds with appropriate HTTP status codes and error messages.
Key Features of GlobalExceptionHandler
:
- Runtime Exceptions: Handles general runtime exceptions, such as
NullPointerException
andRuntimeException
, returning a 500 Internal Server Error response. - Resource Not Found: Manages
ResourceNotFoundException
, returning a 404 Not Found status with a relevant error message. - Business Logic and Data Exceptions: Handles custom exceptions like
ResourceAlreadyExistException
, andDataAccessException
, providing a 400 Bad Request response. - Validation Exceptions: Manages exceptions related to validation, such as
MethodArgumentNotValidException
andConstraintViolationException
, returning detailed validation error messages. - Malformed JSON: Handles
HttpMessageNotReadableException
to catch and respond to improperly formatted JSON in requests. - Method Not Supported: Catches
HttpRequestMethodNotSupportedException
, responding with a 405 Method Not Allowed status.
The application also defines several custom exceptions to manage specific error scenarios:
BusinessLogicException
: Thrown when a business rule is violated.ResourceAlreadyExistException
: Used when an attempt is made to create a resource that already exists.ResourceNotFoundException
: Thrown when a requested resource is not found in the database.
These custom exceptions extend RuntimeException
and are annotated with @ResponseStatus
to map them to specific HTTP
status codes.
To ensure that error responses are consistent, the APIResponse
class is used to structure the response body. It
includes:
- Status: A string indicating the outcome of the request (e.g., "FAILED").
- Errors: A list of
ErrorDTO
objects, each containing afield
and anerrorMessage
to describe the issue.
This structure ensures that clients receive clear and consistent error messages, which can be easily parsed and handled.
Note: This approach to exception handling improves the robustness of the application, making it more maintainable and user-friendly. For more details on how to implement and extend this global exception handler, you can refer to additional resources or documentation available online.
View GlobalExceptionHandler code
package com.ainigma100.customerapi.exception;
import com.ainigma100.customerapi.dto.APIResponse;
import com.ainigma100.customerapi.dto.ErrorDTO;
import com.ainigma100.customerapi.enums.Status;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingPathVariableException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler({RuntimeException.class, NullPointerException.class})
public ResponseEntity<Object> handleRuntimeExceptions(RuntimeException exception) {
APIResponse<ErrorDTO> response = new APIResponse<>();
response.setStatus(Status.FAILED.getValue());
response.setErrors(Collections.singletonList(new ErrorDTO("", "An internal server error occurred")));
log.error("RuntimeException or NullPointerException occurred {}", exception.getMessage());
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler({ResourceNotFoundException.class})
public ResponseEntity<Object> handleResourceNotFoundExceptions(ResourceNotFoundException exception) {
APIResponse<ErrorDTO> response = new APIResponse<>();
response.setStatus(Status.FAILED.getValue());
response.setErrors(Collections.singletonList(new ErrorDTO("", "The requested resource was not found")));
log.error("ResourceNotFoundException occurred {}", exception.getMessage());
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
@ExceptionHandler({ResourceAlreadyExistException.class, DataAccessException.class})
public ResponseEntity<Object> handleOtherExceptions(Exception exception) {
APIResponse<ErrorDTO> response = new APIResponse<>();
response.setStatus(Status.FAILED.getValue());
response.setErrors(Collections.singletonList(new ErrorDTO("", "An error occurred while processing your request")));
log.error("ResourceAlreadyExistException or DataAccessException occurred {}", exception.getMessage());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<Object> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException exception) {
APIResponse<ErrorDTO> response = new APIResponse<>();
response.setStatus(Status.FAILED.getValue());
response.setErrors(Collections.singletonList(new ErrorDTO("", "The requested URL does not support this method")));
log.error("HttpRequestMethodNotSupportedException occurred {}", exception.getMessage());
return new ResponseEntity<>(response, HttpStatus.METHOD_NOT_ALLOWED);
}
@ExceptionHandler({MethodArgumentNotValidException.class, MissingServletRequestParameterException.class, MissingPathVariableException.class})
public ResponseEntity<Object> handleValidationExceptions(Exception exception) {
APIResponse<ErrorDTO> response = new APIResponse<>();
response.setStatus(Status.FAILED.getValue());
List<ErrorDTO> errors = new ArrayList<>();
if (exception instanceof MethodArgumentNotValidException ex) {
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.add(new ErrorDTO(fieldName, errorMessage));
});
} else if (exception instanceof MissingServletRequestParameterException ex) {
String parameterName = ex.getParameterName();
errors.add(new ErrorDTO("", "Required parameter is missing: " + parameterName));
} else if (exception instanceof MissingPathVariableException ex) {
String variableName = ex.getVariableName();
errors.add(new ErrorDTO("", "Missing path variable: " + variableName));
}
log.error("Validation errors: {}", errors);
response.setErrors(errors);
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<APIResponse<ErrorDTO>> handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) {
APIResponse<ErrorDTO> response = new APIResponse<>();
response.setStatus(Status.FAILED.getValue());
response.setErrors(Collections.singletonList(new ErrorDTO("", "Malformed JSON request")));
log.error("Malformed JSON request: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<APIResponse<ErrorDTO>> handleConstraintViolationException(ConstraintViolationException ex) {
List<ErrorDTO> errors = new ArrayList<>();
for (ConstraintViolation<?> violation : ex.getConstraintViolations()) {
errors.add(new ErrorDTO(violation.getPropertyPath().toString(), violation.getMessage()));
}
APIResponse<ErrorDTO> response = new APIResponse<>();
response.setStatus(Status.FAILED.getValue());
response.setErrors(errors);
log.error("Constraint violation errors: {}", errors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
}
In this section, I provide some helper classes that can be reused in Spring Boot applications. These classes are designed to simplify common tasks such as logging, server configuration, and OpenAPI documentation generation.
This configuration class is responsible for generating OpenAPI documentation using SpringDoc. It fetches the OpenAPI JSON from the application's endpoints and saves it as a formatted file.
Key Features:
- Automatically generates OpenAPI documentation in JSON format.
- Saves the documentation to a specified file in the project root. The output file name is specified in the
application.yaml
file. - Handles both HTTP and HTTPS protocols based on the server configuration.
View OpenApiConfig code
package com.ainigma100.customerapi.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.web.client.RestClient;
import java.io.File;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Optional;
@Slf4j
@Configuration
@OpenAPIDefinition(info = @Info(
title = "${springdoc.title}",
version = "${springdoc.version}",
description = "Documentation ${spring.application.name} v1.0"
))
public class OpenApiConfig {
private final Environment environment;
public OpenApiConfig(Environment environment) {
this.environment = environment;
}
@Value("${server.port:8080}")
private int serverPort;
@Value("${openapi.output.file}")
private String outputFileName;
private static final String SERVER_SSL_KEY_STORE = "server.ssl.key-store";
private static final String SERVER_SERVLET_CONTEXT_PATH = "server.servlet.context-path";
@Bean
public CommandLineRunner generateOpenApiJson() {
return args -> {
String protocol = Optional.ofNullable(environment.getProperty(SERVER_SSL_KEY_STORE)).map(key -> "https").orElse("http");
String host = getServerIP();
String contextPath = Optional.ofNullable(environment.getProperty(SERVER_SERVLET_CONTEXT_PATH)).orElse("");
// Define the API docs URL
String apiDocsUrl = String.format("%s://%s:%d%s/v3/api-docs", protocol, host, serverPort, contextPath);
log.info("Attempting to fetch OpenAPI docs from URL: {}", apiDocsUrl);
try {
// Create RestClient instance
RestClient restClient = RestClient.create();
// Fetch the OpenAPI JSON
String response = restClient.get()
.uri(apiDocsUrl)
.retrieve()
.body(String.class);
// Format and save the JSON to a file
formatAndSaveToFile(response, outputFileName);
log.info("OpenAPI documentation generated successfully at {}", outputFileName);
} catch (Exception e) {
log.error("Failed to generate OpenAPI documentation from URL: {}", apiDocsUrl, e);
}
};
}
private String getServerIP() {
try {
return InetAddress.getLocalHost().getHostAddress();
} catch (UnknownHostException e) {
log.error("Error resolving host address", e);
return "unknown";
}
}
private void formatAndSaveToFile(String content, String fileName) {
try {
ObjectMapper objectMapper = new ObjectMapper();
// Enable pretty-print
objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
// Read the JSON content as a JsonNode
JsonNode jsonNode = objectMapper.readTree(content);
// Write the formatted JSON to a file
objectMapper.writeValue(new File(fileName), jsonNode);
} catch (IOException e) {
log.error("Error while saving JSON to file", e);
}
}
}
A servlet filter that logs incoming HTTP requests and outgoing responses. It excludes certain paths, such as those related to Actuator and Swagger, from logging to reduce noise.
Key Features:
- Logs the client's IP address, request URL, and HTTP method.
- Logs the response status after the request is processed.
- Excludes paths related to Actuator, Swagger, and static resources from logging.
View LoggingFilter code
package com.ainigma100.customerapi.filter;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
@Slf4j
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
String clientIP = this.getClientIP(httpServletRequest);
if (this.shouldLogRequest(httpServletRequest)) {
log.info("Client IP: {}, Request URL: {}, Method: {}", clientIP, httpServletRequest.getRequestURL(), httpServletRequest.getMethod());
}
// pre methods call stamps
chain.doFilter(request, response);
// post method calls stamps
if (this.shouldLogRequest(httpServletRequest)) {
log.info("Response status: {}", httpServletResponse.getStatus());
}
}
private boolean shouldLogRequest(HttpServletRequest request) {
// (?i) enables case-insensitive matching, \b matched as whole words
// reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions
return !request.getServletPath().matches("(?i).*\\b(actuator|swagger|api-docs|favicon|ui)\\b.*");
}
private String getClientIP(HttpServletRequest request) {
String clientIP = request.getHeader("Client-IP");
if (clientIP == null || clientIP.isEmpty() || "unknown".equalsIgnoreCase(clientIP)) {
clientIP = request.getHeader("X-Forwarded-For");
}
if (clientIP == null || clientIP.isEmpty() || "unknown".equalsIgnoreCase(clientIP)) {
clientIP = request.getHeader("X-Real-IP");
}
if (clientIP == null || clientIP.isEmpty() || "unknown".equalsIgnoreCase(clientIP)) {
clientIP = request.getRemoteAddr();
}
return clientIP != null ? clientIP : "Unknown";
}
}
This configuration class registers the LoggingFilter
as a Spring bean and sets its priority in the filter chain.
Key Features:
- Registers the
LoggingFilter
with a specified order of execution. - Ensures that the filter applies to all incoming requests.
View FiltersConfig code
package com.ainigma100.customerapi.filter;
import lombok.AllArgsConstructor;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@AllArgsConstructor
@Configuration
public class FiltersConfig {
private final LoggingFilter loggingFilter;
@Bean
public FilterRegistrationBean<LoggingFilter> loggingFilterBean() {
final FilterRegistrationBean<LoggingFilter> filterBean = new FilterRegistrationBean<>();
filterBean.setFilter(loggingFilter);
filterBean.addUrlPatterns("/*");
// Lower values have higher priority
filterBean.setOrder(Integer.MAX_VALUE - 2);
return filterBean;
}
}
This component logs important server details when the application starts, including the server's protocol, host, port, context path, and active profiles. It also provides the URL for accessing the Swagger UI.
Key Features:
- Logs server details and Swagger UI access URL on application startup.
- Supports both HTTP and HTTPS protocols.
- Displays the active Spring profiles.
View ServerDetails code
package com.ainigma100.customerapi.filter;
import lombok.AllArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Optional;
@AllArgsConstructor
@Component
public class ServerDetails {
private static final Logger log = LoggerFactory.getLogger(ServerDetails.class);
private final Environment environment;
private static final String SERVER_SSL_KEY_STORE = "server.ssl.key-store";
private static final String SERVER_PORT = "server.port";
private static final String SERVER_SERVLET_CONTEXT_PATH = "server.servlet.context-path";
private static final String SPRINGDOC_SWAGGER_UI_PATH = "springdoc.swagger-ui.path";
private static final String DEFAULT_PROFILE = "default";
private static final String SPRING_H2_CONSOLE_ENABLED = "spring.h2.console.enabled";
private static final String SPRING_H2_CONSOLE_PATH = "spring.h2.console.path";
private static final String SPRING_DATASOURCE_DRIVER_CLASS_NAME = "spring.datasource.driver-class-name";
@EventListener(ApplicationReadyEvent.class)
public void logServerDetails() {
String protocol = Optional.ofNullable(environment.getProperty(SERVER_SSL_KEY_STORE)).map(key -> "https").orElse("http");
String host = getServerIP();
String serverPort = Optional.ofNullable(environment.getProperty(SERVER_PORT)).orElse("8080");
String contextPath = Optional.ofNullable(environment.getProperty(SERVER_SERVLET_CONTEXT_PATH)).orElse("");
String[] activeProfiles = Optional.of(environment.getActiveProfiles()).orElse(new String[0]);
String activeProfile = (activeProfiles.length > 0) ? String.join(",", activeProfiles) : DEFAULT_PROFILE;
String swaggerUI = Optional.ofNullable(environment.getProperty(SPRINGDOC_SWAGGER_UI_PATH)).orElse("/swagger-ui/index.html");
// Get H2 Console URL if H2 is configured and the console is enabled
String h2ConsoleUrl = getH2ConsoleUrlIfEnabled(protocol, host, serverPort, contextPath);
log.info(
"""
Access Swagger UI URL: {}://{}:{}{}{}
Active Profile: {}{}
""",
protocol, host, serverPort, contextPath, swaggerUI,
activeProfile,
h2ConsoleUrl
);
}
private String getServerIP() {
try {
return InetAddress.getLocalHost().getHostAddress();
} catch (UnknownHostException e) {
log.error("Error resolving host address", e);
return "unknown";
}
}
private String getH2ConsoleUrlIfEnabled(String protocol, String host, String port, String contextPath) {
if (isH2DatabaseConfigured() && isH2ConsoleEnabled()) {
String h2ConsolePath = Optional.ofNullable(environment.getProperty(SPRING_H2_CONSOLE_PATH)).orElse("/h2-console");
return String.format("%nAccess H2 Console URL: %s://%s:%s%s%s", protocol, host, port, contextPath, h2ConsolePath);
}
return "";
}
private boolean isH2DatabaseConfigured() {
// Check if the driver class name is related to H2
String driverClassName = environment.getProperty(SPRING_DATASOURCE_DRIVER_CLASS_NAME);
return Optional.ofNullable(driverClassName)
.map(driver -> driver.equals("org.h2.Driver"))
.orElse(false);
}
private boolean isH2ConsoleEnabled() {
return Optional.ofNullable(environment.getProperty(SPRING_H2_CONSOLE_ENABLED))
.map(Boolean::parseBoolean)
.orElse(false);
}
}
Testing is essential to ensure your application works as expected. This section will cover how to effectively test your Spring Boot application using both unit testing and integration testing strategies, including Behavior Driven Development (BDD).
Unit testing is a method of testing individual units or components of the software in isolation. The main goal is to validate that each unit of the software performs as expected. A "unit" is typically the smallest piece of code that can be logically isolated, such as a function, method, or class.
BDD focuses on writing tests that describe the system's behavior from the user's perspective. It helps improve collaboration between developers, testers, and stakeholders. In Java, you can use JUnit with Mockito to implement BDD.
To make writing BDD tests easier in IntelliJ IDEA, you can create a live template that generates a basic structure for BDD tests. Here’s the template you can use:
<template name="bdd"
value="@org.junit.jupiter.api.Test void given$NAME$_when$NAME2$_then$NAME3$() { // given - precondition or setup org.mockito.BDDMockito.given().willReturn(); // when - action or behaviour that we are going to test // then - verify the output }"
description="Behaviour Driven Development (BDD) test template" toReformat="false" toShortenFQNames="true">
<variable name="NAME" expression="" defaultValue="" alwaysStopAt="true"/>
<variable name="NAME2" expression="" defaultValue="" alwaysStopAt="true"/>
<variable name="NAME3" expression="" defaultValue="" alwaysStopAt="true"/>
<context>
<option name="JAVA_DECLARATION" value="true"/>
</context>
</template>
With the above template, when you type bdd
in your test class, IntelliJ IDEA will generate a skeleton for a BDD-style
test, helping you follow the BDD principles consistently.
In your Spring Boot project, BDD can be implemented as follows:
- Given: Set up the initial context or preconditions for the test.
- When: Perform the action or behavior that you want to test.
- Then: Verify the expected outcome or results.
By structuring your tests this way, you ensure they are clear, concise, and focused on the behavior of the application from the user’s perspective.
The repository layer is responsible for interacting with the database. When testing this layer, focus on ensuring that
your custom query methods behave as expected. If you are only using the provided methods from JpaRepository
without
any custom queries, you do not need to test this layer.
-
@DataJpaTest
: Configures an in-memory database, automatically rolling back transactions after each test. Ensure that you have the H2 dependency in your project for this annotation to work correctly. This annotation also limits the loaded beans to those required for JPA tests. -
@Autowired
: Used to inject the repository instance into your test class, allowing you to call repository methods directly. -
@BeforeEach
: Indicates that the annotated method should be run before each test method in the class. This is commonly used for setting up test data or initializing common objects used in multiple tests. -
@Test
: The most common annotation in JUnit, marking a method as a test method that will be executed when running the test suite.
View CustomerRepositoryTest code
package com.ainigma100.customerapi.repository;
import com.ainigma100.customerapi.entity.Customer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
/*
* @DataJpaTest will automatically configure in-memory database for testing
* and, it will not load annotated beans into the Application Context.
* It will only load the repository class. Tests annotated with @DataJpaTest
* are by default transactional and roll back at the end of each test.
*/
@DataJpaTest
class CustomerRepositoryTest {
@Autowired
private CustomerRepository customerRepository;
private Customer customer;
/**
* This method will be executed before each and every test inside this class
*/
@BeforeEach
void setUp() {
customer = new Customer();
customer.setFirstName("John");
customer.setLastName("Wick");
customer.setEmail("[email protected]");
customer.setPhoneNumber("0123456789");
customer.setDateOfBirth(LocalDate.now().minusYears(18));
}
@Test
void givenValidEmail_whenFindByEmail_thenReturnCustomer() {
// given - precondition or setup
String email = "[email protected]";
customerRepository.save(customer);
// when - action or behaviour that we are going to test
Customer customerFromDB = customerRepository.findByEmail(email).orElse(null);
// then - verify the output
assertNotNull(customerFromDB);
assertEquals(customer.getFirstName(), customerFromDB.getFirstName());
assertEquals(customer.getLastName(), customerFromDB.getLastName());
assertEquals(customer.getEmail(), customerFromDB.getEmail());
assertEquals(customer.getPhoneNumber(), customerFromDB.getPhoneNumber());
assertEquals(customer.getDateOfBirth(), customerFromDB.getDateOfBirth());
}
@Test
void givenInvalidEmail_whenFindByEmail_thenReturnNothing() {
// given - precondition or setup
String email = "[email protected]";
customerRepository.save(customer);
// when - action or behaviour that we are going to test
Customer customerFromDB = customerRepository.findByEmail(email).orElse(null);
// then - verify the output
assertNull(customerFromDB);
}
@Test
void givenNullEmail_whenFindByEmail_thenReturnNothing() {
// given - precondition or setup
String email = null;
customerRepository.save(customer);
// when - action or behaviour that we are going to test
Customer customerFromDB = customerRepository.findByEmail(email).orElse(null);
// then - verify the output
assertNull(customerFromDB);
}
@Test
void givenEmptyEmail_whenFindByEmail_thenReturnNothing() {
// given - precondition or setup
String email = "";
customerRepository.save(customer);
// when - action or behaviour that we are going to test
Customer customerFromDB = customerRepository.findByEmail(email).orElse(null);
// then - verify the output
assertNull(customerFromDB);
}
}
The service layer contains your business logic and interacts with the repository layer. Testing this layer typically involves mocking the repository to isolate the service logic.
@ExtendWith(MockitoExtension.class)
: Enables Mockito annotations in your test class.@InjectMocks
: Injects the mock objects into the service class, allowing you to test the service logic independently of the repository layer. This annotation creates an instance of the class under test and injects the mock dependencies annotated with@Mock
into it.@Mock
: Used to create mock instances of the repository or other dependencies.@DisplayName
: Allows you to provide a custom name for your test methods, making them more descriptive and readable in test reports.
View CustomerServiceImplTest code
package com.ainigma100.customerapi.service.impl;
import com.ainigma100.customerapi.dto.CustomerDTO;
import com.ainigma100.customerapi.dto.CustomerEmailUpdateDTO;
import com.ainigma100.customerapi.entity.Customer;
import com.ainigma100.customerapi.exception.ResourceAlreadyExistException;
import com.ainigma100.customerapi.exception.ResourceNotFoundException;
import com.ainigma100.customerapi.mapper.CustomerMapper;
import com.ainigma100.customerapi.repository.CustomerRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;
/*
* @ExtendWith(MockitoExtension.class) informs Mockito that we are using
* mockito annotations to mock the dependencies
*/
@ExtendWith(MockitoExtension.class)
class CustomerServiceImplTest {
// @InjectMocks creates the mock object of the class and injects the mocks
// that are marked with the annotations @Mock into it.
@InjectMocks
private CustomerServiceImpl customerService;
@Mock
private CustomerRepository customerRepository;
@Mock
private CustomerMapper customerMapper;
private Customer customer;
private CustomerDTO customerDTO;
/**
* This method will be executed before each and every test inside this class
*/
@BeforeEach
void setUp() {
customer = new Customer();
customer.setId(1L);
customer.setFirstName("John");
customer.setLastName("Wick");
customer.setEmail("[email protected]");
customer.setPhoneNumber("0123456789");
customer.setDateOfBirth(LocalDate.now().minusYears(18));
customer.setCreatedDate(LocalDateTime.now());
customer.setUpdatedDate(LocalDateTime.now());
customerDTO = new CustomerDTO();
customerDTO.setId(1L);
customerDTO.setFirstName("John");
customerDTO.setLastName("Wick");
customerDTO.setEmail("[email protected]");
customerDTO.setPhoneNumber("0123456789");
customerDTO.setDateOfBirth(LocalDate.now().minusYears(18));
}
@Test
@DisplayName("Test creating a new customer")
void givenCustomerDTO_whenCreateCustomer_thenReturnCustomerDTO() {
// given - precondition or setup
String email = customerDTO.getEmail();
given(customerRepository.findByEmail(email)).willReturn(Optional.empty());
given(customerMapper.customerDTOToCustomer(customerDTO)).willReturn(customer);
given(customerRepository.save(customer)).willReturn(customer);
given(customerMapper.customerToCustomerDTO(customer)).willReturn(customerDTO);
// when - action or behaviour that we are going to test
CustomerDTO result = customerService.createCustomer(customerDTO);
// then - verify the output
assertThat(result).isNotNull();
assertThat(result.getFirstName()).isEqualTo(customerDTO.getFirstName());
assertThat(result.getLastName()).isEqualTo(customerDTO.getLastName());
assertThat(result.getEmail()).isEqualTo(customerDTO.getEmail());
assertThat(result.getPhoneNumber()).isEqualTo(customerDTO.getPhoneNumber());
verify(customerRepository, times(1)).findByEmail(email);
verify(customerMapper, times(1)).customerDTOToCustomer(customerDTO);
verify(customerRepository, times(1)).save(customer);
verify(customerMapper, times(1)).customerToCustomerDTO(customer);
}
@Test
@DisplayName("Test creating a customer with existing email throws ResourceAlreadyExistException")
void givenExistingEmail_whenCreateCustomer_thenThrowResourceAlreadyExistException() {
// given - precondition or setup
String email = customerDTO.getEmail();
given(customerRepository.findByEmail(email)).willReturn(Optional.of(customer));
// when/then - verify that the ResourceAlreadyExistException is thrown
assertThatThrownBy(() -> customerService.createCustomer(customerDTO))
.isInstanceOf(ResourceAlreadyExistException.class)
.hasMessageContaining("Resource Customer with email : '" + email + "' already exist");
verify(customerRepository, times(1)).findByEmail(customerDTO.getEmail());
verify(customerMapper, never()).customerDTOToCustomer(any(CustomerDTO.class));
verify(customerRepository, never()).save(any(Customer.class));
verify(customerMapper, never()).customerToCustomerDTO(any(Customer.class));
}
@Test
@DisplayName("Test retrieving a customer by ID")
void givenValidId_whenGetCustomerById_thenReturnCustomerDTO() {
// given - precondition or setup
Long id = 1L;
given(customerRepository.findById(id)).willReturn(Optional.of(customer));
given(customerMapper.customerToCustomerDTO(customer)).willReturn(customerDTO);
// when - action or behaviour that we are going to test
CustomerDTO result = customerService.getCustomerById(id);
// then - verify the output
assertThat(result).isNotNull();
assertThat(result.getId()).isEqualTo(customerDTO.getId());
assertThat(result.getFirstName()).isEqualTo(customerDTO.getFirstName());
assertThat(result.getLastName()).isEqualTo(customerDTO.getLastName());
assertThat(result.getEmail()).isEqualTo(customerDTO.getEmail());
assertThat(result.getPhoneNumber()).isEqualTo(customerDTO.getPhoneNumber());
verify(customerRepository, times(1)).findById(id);
verify(customerMapper, times(1)).customerToCustomerDTO(customer);
}
@Test
@DisplayName("Test retrieving a customer by invalid ID throws ResourceNotFoundException")
void givenInvalidId_whenGetCustomerById_thenThrowResourceNotFoundException() {
// given - precondition or setup
Long id = 100L;
given(customerRepository.findById(id)).willReturn(Optional.empty());
// when/then - verify that the ResourceNotFoundException is thrown
assertThatThrownBy(() -> customerService.getCustomerById(id))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessage("Customer with id : '" + id + "' not found");
verify(customerRepository, times(1)).findById(id);
verify(customerMapper, never()).customerToCustomerDTO(any(Customer.class));
}
@Test
@DisplayName("Test updating a customer by ID")
void givenValidIdAndCustomerDTO_whenUpdateCustomer_thenReturnUpdatedCustomerDTO() {
// given - precondition or setup
Long id = 1L;
given(customerRepository.findById(id)).willReturn(Optional.of(customer));
given(customerMapper.customerDTOToCustomer(customerDTO)).willReturn(customer);
given(customerRepository.save(customer)).willReturn(customer);
given(customerMapper.customerToCustomerDTO(customer)).willReturn(customerDTO);
// when - action or behaviour that we are going to test
CustomerDTO result = customerService.updateCustomer(id, customerDTO);
// then - verify the output
assertThat(result).isNotNull();
assertThat(result.getFirstName()).isEqualTo(customerDTO.getFirstName());
assertThat(result.getLastName()).isEqualTo(customerDTO.getLastName());
assertThat(result.getEmail()).isEqualTo(customerDTO.getEmail());
assertThat(result.getPhoneNumber()).isEqualTo(customerDTO.getPhoneNumber());
verify(customerRepository, times(1)).findById(id);
verify(customerMapper, times(1)).customerDTOToCustomer(customerDTO);
verify(customerRepository, times(1)).save(customer);
verify(customerMapper, times(1)).customerToCustomerDTO(customer);
}
@Test
@DisplayName("Test updating a customer by invalid ID throws ResourceNotFoundException")
void givenInvalidIdAndCustomerDTO_whenUpdateCustomer_thenThrowResourceNotFoundException() {
// given - precondition or setup
Long id = 100L;
given(customerRepository.findById(id)).willReturn(Optional.empty());
// when/then - verify that the ResourceNotFoundException is thrown
assertThatThrownBy(() -> customerService.updateCustomer(id, customerDTO))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessage("Customer with id : '" + id + "' not found");
verify(customerRepository, times(1)).findById(id);
verify(customerMapper, never()).customerDTOToCustomer(any(CustomerDTO.class));
verify(customerRepository, never()).save(any(Customer.class));
verify(customerMapper, never()).customerToCustomerDTO(any(Customer.class));
}
@Test
@DisplayName("Test updating a customer's email by ID")
void givenValidIdAndCustomerEmailUpdateDTO_whenUpdateCustomerEmail_thenReturnCustomerDTO() {
// given - precondition or setup
Long id = 1L;
CustomerEmailUpdateDTO customerEmailUpdateDTO = new CustomerEmailUpdateDTO();
customerEmailUpdateDTO.setEmail("[email protected]");
customer.setEmail(customerEmailUpdateDTO.getEmail());
given(customerRepository.findById(id)).willReturn(Optional.of(customer));
given(customerRepository.save(customer)).willReturn(customer);
given(customerMapper.customerToCustomerDTO(customer)).willReturn(customerDTO);
// when - action or behaviour that we are going to test
CustomerDTO result = customerService.updateCustomerEmail(id, customerEmailUpdateDTO);
// then - verify the output
assertThat(result).isNotNull();
assertThat(result.getFirstName()).isEqualTo(customerDTO.getFirstName());
assertThat(result.getLastName()).isEqualTo(customerDTO.getLastName());
assertThat(result.getEmail()).isEqualTo(customerDTO.getEmail());
assertThat(result.getPhoneNumber()).isEqualTo(customerDTO.getPhoneNumber());
verify(customerRepository, times(1)).findById(id);
verify(customerRepository, times(1)).save(customer);
verify(customerMapper, times(1)).customerToCustomerDTO(customer);
}
@Test
@DisplayName("Test updating a customer's email by invalid ID throws ResourceNotFoundException")
void givenInvalidIdAndCustomerEmailUpdateDTO_whenUpdateCustomerEmail_thenThrowResourceNotFoundException() {
// given - precondition or setup
Long id = 100L;
CustomerEmailUpdateDTO customerEmailUpdateDTO = new CustomerEmailUpdateDTO();
customerEmailUpdateDTO.setEmail("[email protected]");
customer.setEmail(customerEmailUpdateDTO.getEmail());
given(customerRepository.findById(id)).willReturn(Optional.empty());
// when/then - verify that the ResourceNotFoundException is thrown
assertThatThrownBy(() -> customerService.updateCustomerEmail(id, customerEmailUpdateDTO))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessage("Customer with id : '" + id + "' not found");
verify(customerRepository, times(1)).findById(id);
verify(customerRepository, never()).save(any(Customer.class));
verify(customerMapper, never()).customerToCustomerDTO(any(Customer.class));
}
@Test
@DisplayName("Test deleting a customer by ID")
void givenValidId_whenDeleteCustomer_thenDeleteCustomer() {
// given - precondition or setup
Long id = 1L;
given(customerRepository.findById(id)).willReturn(Optional.of(customer));
doNothing().when(customerRepository).delete(customer);
// when - action or behaviour that we are going to test
customerService.deleteCustomer(id);
// then - verify the output
verify(customerRepository, times(1)).findById(id);
verify(customerRepository, times(1)).delete(customer);
}
@Test
@DisplayName("Test deleting a customer by invalid ID throws ResourceNotFoundException")
void givenInvalidId_whenDeleteCustomer_thenThrowResourceNotFoundException() {
// given - precondition or setup
Long id = 1L;
given(customerRepository.findById(id)).willReturn(Optional.empty());
// when/then - verify that the ResourceNotFoundException is thrown
assertThatThrownBy(() -> customerService.deleteCustomer(id))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessage("Customer with id : '" + id + "' not found");
verify(customerRepository, times(1)).findById(id);
verify(customerRepository, never()).delete(any(Customer.class));
}
}
The controller layer is responsible for handling HTTP requests and returning appropriate responses. When testing this layer, the goal is to ensure that the controller behaves correctly in response to various inputs and interactions with its dependencies, such as services and mappers.
-
@WebMvcTest
: This annotation is used to load only the components required for testing the controller layer. It configures Spring’s testing support for MVC applications but does not load the full application context, making tests faster and more focused. -
@MockBean
: This annotation is used to create and inject mock instances of the service layer or other dependencies that the controller interacts with. Mocking these dependencies ensures that the test focuses solely on the behavior of the controller without involving actual business logic or database interactions. -
@Autowired
: This annotation is used to inject theMockMvc
andObjectMapper
beans into your test class.MockMvc
is used to simulate HTTP requests and test the controller’s response without starting the server.ObjectMapper
is used for serializing and deserializing JSON objects, making it easier to work with request and response bodies in tests.
-
Mocking Service and Mapper:
- Mock the service and mapper beans to isolate the controller’s logic. By controlling the outputs of the service and mapper methods, you can focus your tests on the controller's behavior and ensure that it processes requests and responses correctly.
-
Testing HTTP Methods:
- Test different HTTP methods (e.g., GET, POST, PUT, DELETE) to ensure that the controller correctly processes requests and returns the expected responses for each type of action.
-
Argument Matchers:
- When setting up mock interactions, use
ArgumentMatchers
likeany(Class.class)
to generalize the input parameters, especially when you do not care about the specific value. Alternatively, use specific matchers likeeq()
when you want to ensure that the method is called with exact values. Choosing the right matcher depends on your test scenario. Understanding when to use each will make your tests more reliable.
- When setting up mock interactions, use
-
Validation of Responses:
- Validate the status code, headers, and response body using methods like
andExpect()
. Ensure that the response structure and content are what you expect. This step is crucial to verify that your API meets its contract.
- Validate the status code, headers, and response body using methods like
-
Use of
ResultActions
:- Capture the result of the
MockMvc
request usingResultActions
. This allows you to chain further verifications on the response, ensuring that all aspects of the response are as expected.
- Capture the result of the
- When writing controller tests, it’s important to decide whether to use
ArgumentMatchers
likeany()
for flexible input matching oreq()
for strict matching based on the context of your test scenario. For more details on using argument matchers oreq()
in Mockito, you can search online resources or refer to Mockito documentation.
View CustomerControllerTest code
package com.ainigma100.customerapi.controller;
import com.ainigma100.customerapi.dto.CustomerDTO;
import com.ainigma100.customerapi.dto.CustomerEmailUpdateDTO;
import com.ainigma100.customerapi.dto.CustomerRequestDTO;
import com.ainigma100.customerapi.enums.Status;
import com.ainigma100.customerapi.mapper.CustomerMapper;
import com.ainigma100.customerapi.service.CustomerService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.time.LocalDate;
import static org.hamcrest.CoreMatchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.willDoNothing;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/*
* @WebMvcTest annotation will load all the components required
* to test the Controller layer. It will not load the service or repository layer components
*/
@WebMvcTest(CustomerController.class)
class CustomerControllerTest {
@Autowired
private ObjectMapper objectMapper;
@Autowired
private MockMvc mockMvc;
@MockBean
private CustomerService customerService;
@MockBean
private CustomerMapper customerMapper;
private CustomerRequestDTO customerRequestDTO;
private CustomerDTO customerDTO;
@BeforeEach
void setUp() {
customerRequestDTO = new CustomerRequestDTO();
customerRequestDTO.setFirstName("John");
customerRequestDTO.setLastName("Wick");
customerRequestDTO.setEmail("[email protected]");
customerRequestDTO.setPhoneNumber("0123456789");
customerRequestDTO.setDateOfBirth(LocalDate.now().minusYears(18));
customerDTO = new CustomerDTO();
customerDTO.setId(1L);
customerDTO.setFirstName("John");
customerDTO.setLastName("Wick");
customerDTO.setEmail("[email protected]");
customerDTO.setPhoneNumber("0123456789");
customerDTO.setDateOfBirth(LocalDate.now().minusYears(18));
}
@Test
void givenCustomerDTO_whenCreateCustomer_thenReturnCustomerDTO() throws Exception {
// given - precondition or setup
given(customerMapper.customerRequestDTOToCustomerDTO(any(CustomerRequestDTO.class)))
.willReturn(customerDTO);
given(customerService.createCustomer(any(CustomerDTO.class))).willReturn(customerDTO);
// when - action or behaviour that we are going to test
ResultActions response = mockMvc.perform(post("/api/v1/customers")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(customerRequestDTO)));
// then - verify the output
response.andDo(print())
// verify the status code that is returned
.andExpect(status().isCreated())
// verify the actual returned value and the expected value
// $ - root member of a JSON structure whether it is an object or array
.andExpect(jsonPath("$.status", is(Status.SUCCESS.getValue())))
.andExpect(jsonPath("$.results.id", is(1)))
.andExpect(jsonPath("$.results.firstName", is("John")))
.andExpect(jsonPath("$.results.lastName", is("Wick")))
.andExpect(jsonPath("$.results.email", is("[email protected]")))
.andExpect(jsonPath("$.results.phoneNumber", is("0123456789")))
.andExpect(jsonPath("$.results.dateOfBirth", is(LocalDate.now().minusYears(18).toString())));
}
@Test
void givenCustomerDTO_whenGetCustomerById_thenReturnCustomerDTO() throws Exception {
// given - precondition or setup
given(customerService.getCustomerById(any(Long.class))).willReturn(customerDTO);
// when - action or behaviour that we are going to test
ResultActions response = mockMvc.perform(get("/api/v1/customers/{id}", 1L)
.contentType(MediaType.APPLICATION_JSON));
// then - verify the output
response.andDo(print())
// verify the status code that is returned
.andExpect(status().isOk())
// verify the actual returned value and the expected value
// $ - root member of a JSON structure whether it is an object or array
.andExpect(jsonPath("$.status", is(Status.SUCCESS.getValue())))
.andExpect(jsonPath("$.results.id", is(1)))
.andExpect(jsonPath("$.results.firstName", is("John")))
.andExpect(jsonPath("$.results.lastName", is("Wick")))
.andExpect(jsonPath("$.results.email", is("[email protected]")))
.andExpect(jsonPath("$.results.phoneNumber", is("0123456789")))
.andExpect(jsonPath("$.results.dateOfBirth", is(LocalDate.now().minusYears(18).toString())));
}
@Test
void givenCustomerDTO_whenUpdateCustomer_thenReturnCustomerDTO() throws Exception {
// given - precondition or setup
given(customerMapper.customerRequestDTOToCustomerDTO(any(CustomerRequestDTO.class)))
.willReturn(customerDTO);
given(customerService.updateCustomer(any(Long.class), any(CustomerDTO.class))).willReturn(customerDTO);
// when - action or behaviour that we are going to test
ResultActions response = mockMvc.perform(put("/api/v1/customers/{id}", 1L)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(customerRequestDTO)));
// then - verify the output
response.andDo(print())
// verify the status code that is returned
.andExpect(status().isOk())
// verify the actual returned value and the expected value
// $ - root member of a JSON structure whether it is an object or array
.andExpect(jsonPath("$.status", is(Status.SUCCESS.getValue())))
.andExpect(jsonPath("$.results.id", is(1)))
.andExpect(jsonPath("$.results.firstName", is("John")))
.andExpect(jsonPath("$.results.lastName", is("Wick")))
.andExpect(jsonPath("$.results.email", is("[email protected]")))
.andExpect(jsonPath("$.results.phoneNumber", is("0123456789")))
.andExpect(jsonPath("$.results.dateOfBirth", is(LocalDate.now().minusYears(18).toString())));
}
@Test
void givenCustomerEmailUpdateDTO_whenUpdateCustomerEmail_thenReturnCustomerDTO() throws Exception {
// given - precondition or setup
CustomerEmailUpdateDTO customerEmailUpdateDTO = new CustomerEmailUpdateDTO();
customerEmailUpdateDTO.setEmail("[email protected]");
customerDTO.setEmail(customerEmailUpdateDTO.getEmail());
given(customerService.updateCustomerEmail(any(Long.class), any(CustomerEmailUpdateDTO.class)))
.willReturn(customerDTO);
// when - action or behaviour that we are going to test
ResultActions response = mockMvc.perform(patch("/api/v1/customers/{id}/email", 1L)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(customerEmailUpdateDTO)));
// then - verify the output
response.andDo(print())
// verify the status code that is returned
.andExpect(status().isOk())
// verify the actual returned value and the expected value
// $ - root member of a JSON structure whether it is an object or array
.andExpect(jsonPath("$.status", is(Status.SUCCESS.getValue())))
.andExpect(jsonPath("$.results.id", is(1)))
.andExpect(jsonPath("$.results.firstName", is("John")))
.andExpect(jsonPath("$.results.lastName", is("Wick")))
.andExpect(jsonPath("$.results.email", is("[email protected]")))
.andExpect(jsonPath("$.results.phoneNumber", is("0123456789")))
.andExpect(jsonPath("$.results.dateOfBirth", is(LocalDate.now().minusYears(18).toString())));
}
@Test
void givenCustomerDTO_whenDeleteCustomer_thenReturnCustomerDTO() throws Exception {
// given - precondition or setup
willDoNothing().given(customerService).deleteCustomer(any(Long.class));
// when - action or behaviour that we are going to test
ResultActions response = mockMvc.perform(delete("/api/v1/customers/{id}", 1L)
.contentType(MediaType.APPLICATION_JSON));
// then - verify the output
response.andDo(print())
// verify the status code that is returned
.andExpect(status().isOk())
// verify the actual returned value and the expected value
// $ - root member of a JSON structure whether it is an object or array
.andExpect(jsonPath("$.status", is(Status.SUCCESS.getValue())));
}
}
Integration Testing is the phase in software testing where individual units or components of an application are combined and tested as a group. The main goal of integration testing is to verify the interactions between different modules and to ensure that they work together as expected. In a Spring Boot application, this typically involves testing the full stack, including the controller, service, repository layers, and the actual database.
In this project, we use Testcontainers to facilitate integration testing with a real PostgreSQL database running in a Docker container. This ensures that our tests run in an environment that closely mirrors production.
To run these integration tests, Docker needs to be running on your machine. However, if Docker is not available or running, the tests will automatically be skipped. This is managed by the following annotation in the test class:
@Testcontainers(disabledWithoutDocker = true)
What This Means:
- If Docker is running: The integration tests will run as usual.
- If Docker is not running: The tests won't run, so you won't get any errors related to Docker not being available.
This setup ensures that you won't face issues with integration tests if you don’t have Docker running. It’s a way to avoid unnecessary errors and make sure you can keep working without interruptions, especially if you're new and still setting up your environment.
If you need to run the integration tests, just make sure Docker is installed and running on your machine.
Disclaimer: The practices outlined here reflect my personal approach based on what I have learned and observed from various resources. While I believe these practices can help in building clean, maintainable, and scalable Spring Boot applications, they are by no means the only way to approach development. I encourage you to explore other perspectives, adapt these practices to your needs, and continuously evolve your methods as new tools and techniques emerge.
When developing Spring Boot applications, following best practices ensures your code is clean, maintainable, and scalable. Below are some key practices to keep in mind:
- Purpose: DTOs (Data Transfer Objects) are used to encapsulate data transferred between the client and server. By using DTOs, you prevent exposing your JPA entities directly to the client, which can mitigate security risks and decouple your API's data model from its internal domain model.
- Implementation:
- Use tools like MapStruct or write custom mappers to convert between entities and DTOs.
- Ensure that your controllers interact with services using DTOs, not entities, to maintain a clear separation of concerns.
- Purpose: Dependency Injection (DI) allows for the automatic management of your application’s dependencies, promoting loose coupling and easier testing.
- Best Practices:
- Use Constructor Injection: Prefer constructor injection over field injection as it makes your classes easier to test, clearly indicates the dependencies of your class, and supports immutability.
- Avoid Field Injection: Field injection can lead to issues in unit testing and hides dependencies, making the code harder to understand and maintain.
- Use
@Autowired
: Spring’s@Autowired
annotation can be used to inject dependencies, but constructor injection is more explicit and recommended.
- Purpose: Centralized exception handling allows you to manage errors consistently across your application, improving the user experience and simplifying error management.
- Implementation:
- Use
@ControllerAdvice
to create a global exception handler that handles exceptions thrown across the application. - Use
@ExceptionHandler
within@ControllerAdvice
to specify custom handling logic for specific exception types. - Return structured error responses using a consistent format, which can be encapsulated in a DTO like
APIResponse
.
- Use
- Purpose: A well-organized codebase makes the project easier to navigate, understand, and maintain, especially as it grows in complexity.
- Best Practices:
- Separate Concerns: Maintain a clean and organized project structure by separating concerns into different layers (e.g., controllers, services, repositories).
- Keep Methods Small: Break down large methods into smaller, single-purpose methods to enhance readability and maintainability. Each method should do one thing and do it well.
- Avoid Repetition: Follow the DRY (Don't Repeat Yourself) principle by abstracting common logic into reusable methods or classes.
- Purpose: Consistent naming conventions improve the readability and maintainability of your code, making it easier for other developers (and your future self) to understand the purpose of classes, methods, and variables.
- Best Practices:
- Packages: Use lowercase and singular names for packages (e.g.,
com.ainigma100.customerapi.controller
). - Classes: Follow PascalCase for class names (e.g.,
CustomerService
), and ensure names are meaningful and descriptive. - Methods: Use camelCase for method names (e.g.,
getCustomerById
) and keep method names descriptive to reflect their actions. - Endpoints: Use lowercase and hyphen-separated words for REST API endpoint paths (e.g.,
/api/v1/customers
), and use plural nouns for collections (e.g.,/customers
).
- Packages: Use lowercase and singular names for packages (e.g.,
- Purpose: Wrapping API responses in a standardized object (like
APIResponse
) ensures consistent structure, improves readability, and makes it easier to include additional metadata (e.g., status, errors) along with the actual data. - Implementation:
- Standardized Structure: Define a generic response class that encapsulates the response data, status, and any errors. This approach provides a uniform response format across all endpoints.
- Builder Pattern: Use the Builder pattern to construct response objects, which enhances readability and flexibility by allowing you to add only the fields you need.
- Consistency: Return response objects in all your controller methods to ensure that clients receive a consistent response format, which simplifies client-side parsing and error handling.
- Purpose: Breaking down complex logic into smaller, manageable pieces makes your code easier to understand, test, and maintain.
- Best Practices:
- Refactor Large Methods: If a method is doing too much, refactor it into smaller methods that each handle a specific part of the logic. This improves readability, makes your code more modular, and simplifies testing.
- Single Responsibility Principle (SRP): Ensure that each class and method has only one responsibility. This principle helps to make your code more focused, easier to maintain, and less prone to errors.
- Avoid Deep Nesting: Deeply nested code blocks can be hard to follow and maintain. Consider early exits (using return statements) and breaking nested blocks into separate methods to enhance clarity.
- Purpose: Well-documented code helps new developers understand the application quickly and ensures that the purpose of classes and methods is clear.
- Best Practices:
- Use Swagger for API Documentation: Instead of using only the traditional Javadoc comments, leverage Swagger annotations to document your APIs. This approach provides interactive documentation that clients can use to understand and test your services.
- Descriptive Method and Property Names: Ensure that your method and property names are self-explanatory, making the code easier to read and understand without requiring extensive comments.
- Generate and Share Swagger Documentation: Utilize classes to generate Swagger documentation and export it as a file. This allows you to share the API documentation easily with others, ensuring they have the necessary information to interact with your services. You can reuse the classes I wrote in the current project.
- Inline Comments: Use inline comments sparingly to explain complex logic or to provide context about why certain decisions were made. Comments should add value by explaining the "why" behind the code, not the "what." If your code is clear on what it does, it is not mandatory to add comments.
- Purpose: Implementing a robust version control and CI/CD (Continuous Integration/Continuous Deployment) pipeline ensures that your codebase is always in a deployable state and that changes are tracked, reviewed, and integrated systematically.
- Best Practices:
- Git: Use Git for version control, and follow a branching strategy (e.g., GitFlow) to manage feature development, bug fixes, and releases. Commit often with meaningful commit messages to document the history of your project.
- Code Reviews: Incorporate code reviews into your development process to catch issues early, share knowledge across the team, and maintain code quality.
- CI/CD Pipeline: Set up a CI/CD pipeline using tools like Jenkins, GitHub Actions, GitLab CI, or CircleCI to
automate the building, testing, and deployment of your application. This pipeline should include:
- Automated Testing: Ensure that all tests run automatically on every commit to catch issues early.
- Code Quality Checks: Integrate tools like SonarQube or Checkstyle to enforce coding standards and detect potential issues.
- Deployment Automation: Automate the deployment process to reduce manual errors and speed up delivery.
- Purpose: Database migration tools like Liquibase and Flyway help manage schema changes in a consistent and controlled manner. They are particularly useful in environments where the database schema evolves over time.
- When to Use:
- Use Case: If your application requires frequent schema changes, or if you work in a team where multiple developers are modifying the database, using a migration tool is essential. It ensures that all changes are versioned, documented, and applied consistently across different environments (development, testing, production).
- When Not Needed: If your application uses a fixed schema that rarely changes, or if you're using a database with a predefined schema where you don't manage the tables (e.g., a third-party service), you might not need a migration tool. In such cases, focusing on data access rather than schema management is more appropriate.
- Best Practices:
- Version Control for Migrations: Always check your migration scripts into version control alongside your application code. This ensures that schema changes are versioned with the corresponding application changes.
- Automate Migrations: Integrate your migration tool into your CI/CD pipeline to ensure that migrations are applied automatically during deployment, reducing the risk of human error.
-
Purpose: SonarQube helps improve code quality by automatically detecting issues like bugs, security vulnerabilities, and code smells.
-
Best Practices:
- Integrate into CI/CD: Add SonarQube to your CI/CD pipeline so that your code is checked for quality every time you commit or create a pull request.
- Set Quality Gates: Define thresholds for things like code coverage and bugs in SonarQube to ensure your code meets quality standards before it’s merged.
- Act on Reports: Regularly review SonarQube reports and fix any issues it finds, focusing on critical problems first.
- Team Awareness: Make sure your team understands how to use SonarQube reports to improve their code.
- Manage Technical Debt: Use SonarQube to track and reduce technical debt by identifying areas that need refactoring.
Pagination is an essential feature when dealing with large datasets in any application. It helps in breaking down large amounts of data into manageable chunks, improving both performance and user experience. In this section, I will walk through an enhanced pagination implementation using Spring Boot that you can adapt for your own projects.
This example will demonstrate how to retrieve paginated and sorted customer data from the database, leveraging custom search criteria.
The CustomerSearchCriteriaDTO
class is used to encapsulate the search criteria that the client sends to the server.
This DTO not only includes pagination parameters like page and size but also allows for sorting and filtering based on
various fields like firstName, lastName, email, etc.
It’s important to note that all the search fields firstName
, lastName
, email
, phoneNumber
, and dateOfBirth
are
optional. You can use them to filter records based on the criteria you need. If no filtering is needed, you can simply
pass the pagination and sorting details.
View CustomerSearchCriteriaDTO code
package com.ainigma100.customerapi.dto;
import com.ainigma100.customerapi.utils.SortItem;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
import java.util.List;
@Setter
@Getter
@RequiredArgsConstructor
public class CustomerSearchCriteriaDTO {
private String firstName;
private String lastName;
private String email;
private String phoneNumber;
private LocalDate dateOfBirth;
@NotNull(message = "page cannot be null")
@PositiveOrZero(message = "page must be a zero or a positive number")
private Integer page;
@Schema(example = "10")
@NotNull(message = "size cannot be null")
@Positive(message = "size must be a positive number")
private Integer size;
private List<SortItem> sortList;
}
For example, you can send a request like this:
{
"page": 0,
"size": 10,
"sortList": [
{
"field": "id",
"direction": "ASC"
}
]
}
Assuming you have this record in your database:
{
"id": 2,
"firstName": "marco",
"lastName": "polo",
"email": "[email protected]",
"phoneNumber": "1234567891",
"dateOfBirth": "2004-08-13"
}
If you need to filter only by firstName, you can send a request like this:
{
"firstName": "marco",
"page": 0,
"size": 10,
"sortList": [
{
"field": "id",
"direction": "ASC"
}
]
}
Note: You can experiment with different combinations of filters and pagination parameters based on your needs.
The following query is responsible for handling pagination and filtering.
@Query(value = """
select cus from Customer cus
where ( :#{#criteria.firstName} IS NULL OR LOWER(cus.firstName) LIKE LOWER( CONCAT(:#{#criteria.firstName}, '%') ) )
and ( :#{#criteria.lastName} IS NULL OR LOWER(cus.lastName) LIKE LOWER( CONCAT(:#{#criteria.lastName}, '%') ) )
and ( :#{#criteria.email} IS NULL OR LOWER(cus.email) LIKE LOWER( CONCAT('%', :#{#criteria.email}, '%') ) )
and ( :#{#criteria.phoneNumber} IS NULL OR LOWER(cus.phoneNumber) LIKE LOWER( CONCAT('%', :#{#criteria.phoneNumber}, '%') ) )
and ( :#{#criteria.dateOfBirth} IS NULL OR cus.dateOfBirth = :#{#criteria.dateOfBirth} )
""")
Page<Customer> getAllCustomersUsingPagination(
@Param("criteria") CustomerSearchCriteriaDTO customerSearchCriteriaDTO,
Pageable pageable);
The query allows you to filter customer data based on the criteria you provide. Here’s how it works:
- Flexible Filters: Each field, like
firstName
oremail
, can be used to filter results. If you don’t need a specific filter, you can leave it out, and the query will ignore that field. - Examples:
- Filtering by First Name: If you provide a
firstName
, the query looks for customers whose names start with that value. - Flexible Email Search: The query can find customers even if you provide only part of their email address.
- Filtering by First Name: If you provide a
This approach makes the query easy to use and flexible for different search needs.
To facilitate pagination and sorting, a utility class is often needed. Below is an example of a utility class that helps in creating pageable objects based on the SortItem list provided in the DTO.
View Utils code
package com.ainigma100.customerapi.utils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
@Slf4j
public class Utils {
// Private constructor to prevent instantiation
private Utils() {
throw new IllegalStateException("Utility class");
}
/**
* Retrieves a value from a Supplier or sets a default value if a NullPointerException occurs.
* Usage example:
*
* <pre>{@code
* // Example 1: Retrieve a list or provide an empty list if null
* List<Employee> employeeList = Utils.retrieveValueOrSetDefault(() -> someSupplierMethod(), new ArrayList<>());
*
* // Example 2: Retrieve an Employee object or provide a default object if null
* Employee emp = Utils.retrieveValueOrSetDefault(() -> anotherSupplierMethod(), new Employee());
* }</pre>
*
* @param supplier the Supplier providing the value to retrieve
* @param defaultValue the default value to return if a NullPointerException occurs
* @return the retrieved value or the default value if a NullPointerException occurs
* @param <T> the type of the value
*/
public static <T> T retrieveValueOrSetDefault(Supplier<T> supplier, T defaultValue) {
try {
return supplier.get();
} catch (NullPointerException ex) {
log.error("Error while retrieveValueOrSetDefault {}", ex.getMessage());
return defaultValue;
}
}
public static Pageable createPageableBasedOnPageAndSizeAndSorting(List<SortItem> sortList, Integer page, Integer size) {
List<Sort.Order> orders = new ArrayList<>();
if (sortList != null) {
// iterate the SortList to see based on which attributes we are going to Order By the results.
for (SortItem sortValue : sortList) {
orders.add(new Sort.Order(sortValue.getDirection(), sortValue.getField()));
}
}
return PageRequest.of(
Optional.ofNullable(page).orElse(0),
Optional.ofNullable(size).orElse(10),
Sort.by(orders));
}
}
The SortItem class encapsulates the sorting criteria, including the field to be sorted and the direction (ascending or descending).
View SortItem code
package com.ainigma100.customerapi.utils;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import org.springframework.data.domain.Sort;
import java.io.Serializable;
@Getter
public class SortItem implements Serializable {
@Schema(example = "id") // set a default sorting property for swagger
private String field;
private Sort.Direction direction;
}
Note: You can check the implementation and the testing of this feature by reading the code.
By implementing enhanced pagination, you ensure that your application can efficiently handle large datasets while providing users with a responsive experience. This approach is versatile and can be adapted to various other scenarios in your application.
The configuration in this section is not part of the current project but is provided to share it with the community for educational purposes. You will not find it in the codebase, but you may find it useful if you need to generate client code for an external API.
In many cases, when working with external APIs, the provider may supply an OpenAPI (Swagger) specification. Instead of
manually creating models and client code, you can use the openapi-generator-maven-plugin
to automate this process.
This saves development time and ensures the API client and models are always in sync with the specification.
The openapi-generator-maven-plugin
is particularly useful in scenarios such as:
- Integrating with Third-Party APIs: You can generate client code automatically based on external API specifications (Swagger).
- Maintaining Consistency: When APIs change frequently, auto-generating code ensures that your models and API clients remain consistent with the latest API definitions.
- Avoiding Manual Model Creation: Instead of creating Java models for responses manually, you can generate them directly from the Swagger spec.
- Time-Saving: Automating the process of generating client code from API definitions saves time and effort when integrating with complex or frequently changing APIs.
- Auto-Generated API Clients: Automatically generate Java client code to call external APIs, avoiding the need to manually code the clients.
- Consistent Models: Ensure consistency between the API models and the actual API by generating them from the spec.
- Faster Development: Automates the client code generation process, allowing you to quickly integrate with third-party APIs.
- Swagger Files for External API Calls: When provided with a Swagger spec for an external API, you can generate the client code and models instead of writing them by hand.
Note: Most of the time I use it to check the APIs and to avoid implementing the Java objects they return.
Although this is not part of the current project, here's an example of how you could configure the plugin to generate client code for external APIs:
Create a folder named swagger inside src/main/resources and place your OpenAPI specification files (in JSON or YAML format) inside.
├── src/
│ ├── main/
│ │ ├── java/
│ │ └── resources/
│ │ └── swagger/
│ │ ├── department-api.json
Before adding new dependencies, check if you already have similar dependencies to avoid conflicts. Add the necessary properties and dependencies:
<properties>
<!-- other properties -->
<!-- Add the latest versions -->
<springdoc-openapi-starter-webmvc-ui.version>2.6.0</springdoc-openapi-starter-webmvc-ui.version>
<openapi-generator-maven-plugin.version>7.8.0</openapi-generator-maven-plugin.version>
<jackson-databind-nullable.version>0.2.6</jackson-databind-nullable.version>
</properties>
Add the required dependencies:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc-openapi-starter-webmvc-ui.version}</version>
</dependency>
<dependency>
<groupId>org.openapitools</groupId>
<artifactId>jackson-databind-nullable</artifactId>
<version>${jackson-databind-nullable.version}</version>
</dependency>
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>${openapi-generator-maven-plugin.version}</version>
<executions>
<!-- Generate Department API client code -->
<execution>
<id>department-api</id>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/swagger/department-api.json</inputSpec>
<generatorName>spring</generatorName>
<output>${project.build.directory}/gen-openapi/department</output>
<apiPackage>${project.groupId}.department.api</apiPackage>
<modelPackage>${project.groupId}.department.model</modelPackage>
<generateSupportingFiles>true</generateSupportingFiles>
<configOptions>
<delegatePattern>true</delegatePattern>
<interfaceOnly>true</interfaceOnly>
<useSpringBoot3>true</useSpringBoot3>
<cleanupOutput>true</cleanupOutput>
</configOptions>
</configuration>
</execution>
<!-- You can add more files here as a new execution. Just be sure to have different 'id' -->
</executions>
</plugin>
Run the following Maven command to generate the code based on the OpenAPI specifications.
mvn clean install
As soon as you run the above command, you will notice that some generated files have been
created in the target folder. You will find inside there the APIs
and the Models
.
├── target/
│ └── gen-openapi/
│ ├── department/
If after running mvn clean install
you find that the generated models are not imported into your project, you may need
to manually update the project sources. Follow these steps:
- Right-click on your project in your IntelliJ.
- Navigate to
Maven
→Generate Sources and Update Folders
.
This action triggers Maven to re-import the generated sources into your project. After completion, verify that the generated models are now accessible within your project structure.
The openapi-generator-maven-plugin
offers a variety of configurations to customize the generated code, such as
generating different types of API clients, models, or server stubs. You can explore more configurations and options in
the official plugin documentation
found https://github.com/OpenAPITools/openapi-generator/tree/master/modules/openapi-generator-maven-plugin.
Using tools like openapi-generator-maven-plugin
can save time and effort when working with external APIs. It automates
the creation of client code and models, ensuring that they are always consistent with the API spec, without manual
intervention. Although not included in this guide's codebase, this is a useful technique for certain scenarios where you
frequently call external services based on OpenAPI specifications.
Feedback and contributions are welcome! If you have suggestions, improvements, or additional insights, please feel free to share. Together, we can make this a valuable resource for anyone learning Spring Boot 3.