Spring Basics
This section covers the basics of Spring Boot and JPA. We will build a simple application that will:
- Store information about university courses in a database
- Serve some endpoints to retrieve information about these courses
- Serve an endpoint to add a new course
- Load development data when on a development environment
Some basic tests will also be added.
Project Structure
A typical Spring Boot project will have the following directory structure:
src
main
java
nl/tudelft/project
config/...
controller/...
dto/...
model/...
repository/...
service/...
Application.java
...
resources
...
application.yaml
test/...
build.gradle.kts
README.md
The following files and directories are important:
build.gradle.kts
Here the dependencies and build configuration for the project is stored.src/main/resources/application.yaml
Here the project itself can be configured.src/main/java/.../config
Configuration classes for Spring.src/main/java/.../controller
Controller classes for Spring. These define the endpoints.src/main/java/.../dto
DTO classes. These define what data will be sent between the client and server.src/main/java/.../model
Model classes. These define what data is stored and how the data is stored in the database.src/main/java/.../repository
Repository classes. These define the methods to query the database.src/main/java/.../service
Service classes for Spring. These define the functionality of the application.
Build Configuration
In the build.gradle.kts
file you might find the following sections.
plugins
This section defines which gradle plugins are used to build the project.
You will for sure find: java
, org.springframework.boot
, and io.spring.dependency-management
here.
The first two are self-explanatory.
io.spring.dependency-management
tries to figure out correct versions for the dependencies provided in the dependencies
section, so we do not have to specify those.
In later sections, we will add more plugins here.
group
and version
Group and version are used to define under what group and with what version the project should be deployed.
This is especially important for libraries like Librador and Labradoor, but we try to use semantic versioning for all projects.
This means that:
- A small update increases the patch version by one: major.minor.*patch
- A bigger update, typically one that introduces some non-backwards compatible functionality, increases the minor version by one: major.*minor.patch
- A large update, for example a big rewrite, increases the major version by one: *major.minor.patch
repositories
In this section, all places dependencies can be pulled from are configured.
For now, we just use mavenCentral()
, but in the future when we want to pull from our own GitLab repositories, we add them here.
dependencies
Here all dependencies used in the project are configured. We will use some dependencies for Spring, JPA, Hibernate, Lombok, and ModelMapper. These will be explained later.
Final build.gradle.kts
Our build.gradle.kts
now looks as follows:
plugins {
java
id("org.springframework.boot") version "3.2.4"
id("io.spring.dependency-management") version "1.1.3"
}
group = "nl.tudelft"
version = "0.0.1"
java {
sourceCompatibility = JavaVersion.VERSION_21
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
repositories {
mavenCentral()
}
dependencies {
// Spring
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web")
developmentOnly("org.springframework.boot:spring-boot-devtools")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
// Database
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-jdbc")
runtimeOnly("com.h2database:h2")
// Testing
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.assertj:assertj-core:3.24.2")
// Lombok
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
// Modelmapper
implementation("org.modelmapper:modelmapper:3.1.1")
}
tasks.withType<Test> {
useJUnitPlatform()
}
Application Configuration
Our initial application.yaml
will be very simple.
In the future, quite some more configuration will be added here.
spring:
profiles:
active: development
datasource:
url: jdbc:h2:mem:devdb
username: sa
password:
server:
port: 8200
server.port
sets the server port to 8200.
spring.profiles.active
sets the active profile.
We will use the profile to enable some configuration only when certain profiles are enabled.
spring.datasource
configures the database.
We will use an in-memory database for development.
Creating the Application
Now that all configuration is set up, we can start implementing the actual application. Any Spring application needs a main class that starts Spring:
@SpringBootApplication
public class ExampleApplication {
public static void main(String[] args) {
SpringApplication.run(ExampleApplication.class, args);
}
}
@SpringBootApplication
class ExampleApplication
fun main(args: Array<String>) {
runApplication<ExampleApplication>(*args)
}
Adding an Entity
Let's create our first entity: Course
.
This will represent a course in a university, for example "Introduction to Programming".
Every entity needs to have an ID.
Additionally, we will give every course a name, a course code, a number of ECs, and a teacher.
We need to make sure every field is properly validated.
@Min
is used to set a minimum on numbers.
@NotBlank
is used to disallow strings to be blank or null.
@NotNull
is used to disallow null
values.
The identifier field needs to be annotated with @Id
and a @GeneratedValue
, which specifies a strategy.
We usually use GenerationType.IDENTITY
, which will make the column AUTO_INCREMENT
.
Lombok is used to generate getters, setters, equals methods, hashCode methods, constructors, and builders.
The Lombok annotations are pretty self-explanatory: @Getter
, @Setter
, @AllArgsContructors
, @Builder
, etc.
The @Data
annotation creates getters and setters for every field and generates an equals and hashCode method.
For every field that has a default value, @Builder.Default
needs to be added if we want a builder to be generated.
This is our final Course
class.
@Data
@Entity // Tell Spring this is an entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank // Names cannot be blank
private String name;
@NotBlank // Code cannot be blank
private String code;
@Min(0) // Number of ECs cannot be negative
@NotNull
@Builder.Default
private Integer numberOfEcs = 5;
@NotBlank // Teacher cannot be blank
private String teacher;
}
@Entity // Tell Spring this is an entity
data class Course(
@NotBlank // Names cannot be blank
var name: String,
@NotBlank // Codes cannot be blank
var code: String,
@Min(0) // Number of ECs cannot be negative
var numberOfEcs: Int = 5,
@NotBlank // Teacher cannot be blank
var teacher: String,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
)
Creating a Repository
To access the stored entities, we need a repository.
Luckily for us, JPA generates a lot of the methods for us.
We just need to extend JpaRepository<ENTITY, ID>
.
It is possible to add our own custom queries to a repository. We don't do this by writing SQL queries (although this is possible), but instead we create an abstract method whose name will get parsed to a valid query. See the documentation for more details.
public interface CourseRepository extends JpaRepository<Course, Long> {
Optional<Course> findByCode(String code);
}
interface CourseRepository : JpaRepository<Course, Long> {
fun findByCode(code: String): Optional<Course>
// Alternatively, since Kotlin deals with nulls explicitly
fun findByCode(code: String): Course?
}
Adding Logic
Now that we have data, it makes sense to do something with it.
This is what services are for.
We will create a CourseService
with some methods to retrieve courses and add courses.
We should generate a constructor that takes all dependencies as arguments.
Spring will automatically construct the service with the right dependencies.
This is provided that the dependencies are beans (see Beans).
@Autowired
can also be used, but we avoid this as much as possible (see Testing).
The @Service
annotation tells Spring that this is a service.
Methods that should be executed in a database transaction are annotated with @Transactional
.
@Service
@AllArgsConstructor
public class CourseService {
private final CourseRepository courseRepository;
public List<Course> getAllCourses() {
return courseRepository.findAll();
}
public Course getCourseById(Long id) {
return courseRepository.findById(id).getOrNull();
}
public List<Course> filterMathsCourses(List<Course> courses) {
// Courses of the form CSEX2XX are maths courses
return courses.stream().filter(c -> c.getCode().charAt(4) == '2').toList();
}
@Transactional
public Course createCourse(Course course) {
return courseRepository.save(course);
}
}
@Service
class CourseService(
val courseRepository: CourseRepository
) {
fun getAllCourses(): List<Course> = courseRepository.findAll()
fun getCourseById(id: Long): Course? =
courseRepository.findById(id).getOrNull()
fun filterMathsCourses(courses: List<Course>): List<Course> =
courses.filter { it.code[4] == '2' }
@Transactional
fun createCourse(course: Course): Course = courseRepository.save(course)
}
DTOs
Before we can create endpoints, we need some way to transfer data between client and server. Sending the raw entities can expose information we do not want exposed. We use DTOs for this purpose. There are several types:
- View DTOs: These are views into some entity. They might be an almost exact copy of the entity or only expose a little bit of information.
- Create DTOs: These contain information about how to create an entity.
- Patch DTOs: These contain information about how to modify and entity.
- Id DTOs: We will work with these later. They wrap the IDs of entities.
Let us create a View and a Create DTO:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CourseViewDTO {
private Long id;
private String name;
private String code;
private Integer numberOfEcs;
private String teacher;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CourseCreateDTO {
@NotBlank
private String name;
@NotBlank
private String code;
@Min(0)
@NotNull
private Integer numberOfEcs;
@NotBlank
private String teacher;
}
data class CourseViewDTO(
val id: Long,
val name: String,
val code: String,
val numberOfEcs: Int,
val teacher: String
) {
companion object {
fun fromCourse(course: Course): CourseViewDTO =
CourseViewDTO(course.id!!, course.name, course.code,
course.numberOfEcs, course.teacher)
}
}
data class CourseCreateDTO(
@NotBlank
val name: String,
@NotBlank
val code: String,
@Min(0)
val numberOfEcs: Int,
@NotBlank
val teacher: String
) {
fun toCourse(): Course = Course(name, code, numberOfEcs, teacher)
}
ModelMapper
To ease converting between entities and DTOs, we use ModelMapper.
Any class can be converted to another by simple calling .map(from, To.class)
on an instance of ModelMapper
.
Constructing a ModelMapper
instance is expensive so we usually create singletons for ModelMappers.
In this case, we will use a bean defined in a configuration class.
Info
ModelMapper requires a constructor without arguments and getters and setters for all fields.
The following will make a ModelMapper available throughout the application:
@Configuration
public class ModelMapperConfig {
@Bean
public ModelMapper modelMapper() {
return new ModelMapper();
}
}
Warning
It is counter-productive to use ModelMapper for Kotlin as it requires all classes to have their fields defined as lateinit var
.
This does not allow the use of data class
es and thus requires you to use Lombok or write all equals, hashCodes, and toString methods yourself.
If this is a tradeoff you're willing to make then you can use this.
@Configuration
class ModelMapperConfig {
@Bean
fun modelMapper(): ModelMapper = ModelMapper()
}
Adding Endpoints
We now have everything we need to add endpoints. Let us add three endpoints:
GET /course/all
will retrieve all courses from the database and return them as a JSON list.GET /course/{id}
will retrieve the course with the given ID from the database and return it as a JSON object.POST /course
will create a course based on the given JSON request body and return the ID of the created course.
To define endpoints, we use a controller.
In this case, we will use @Controller
, since we usually use it for our applications,
but we could use @RestController
here, as we only have JSON request and response bodies.
Using @RestController
allows you to remove all @ResponseBody
and @RequestBody
annotations.
Similar to the service, we add all dependencies as arguments to a constructor.
@Controller
@AllArgsConstructor
@RequestMapping("course")
public class CourseController {
private final CourseService courseService;
private final ModelMapper mapper;
@GetMapping("all")
public @ResponseBody List<CourseViewDTO> getAllCourses() {
return courseService.getAllCourses().stream()
.map(c -> mapper.map(c, CourseViewDTO.class)).toList();
}
@GetMapping("{id}")
public @ResponseBody CourseViewDTO getById(@PathVariable Long id) {
return mapper.map(courseService.getCourseById(id), CourseViewDTO.class);
}
@PostMapping
public @ResponseBody Long createCourse(@RequestBody CourseCreateDTO createDTO) {
return courseService
.createCourse(mapper.map(createDTO, Course.class)).getId();
}
}
@Controller
@RequestMapping("course")
class CourseController(
val courseService: CourseService
) {
@ResponseBody
@GetMapping("all")
fun getAllCourses(): List<CourseViewDTO> =
courseService.getAllCourses().map { CourseViewDTO.fromCourse(it) }
@ResponseBody
@GetMapping("{id}")
fun getById(@PathVariable id: Long): CourseViewDTO? =
courseService.getCourseById(id)?.let { CourseViewDTO.fromCourse(it) }
@ResponseBody
@PostMapping
fun createCourse(@RequestBody createDTO: CourseCreateDTO): Long =
courseService.createCourse(createDTO.toCourse()).id!!
}
After this, it should be possible to run the application and access the endpoints via HTTP.
Note
Make sure you do not have spring security in your dependencies if you would like to test this. If you do, you need to remove it or allow the added endpoints to be accessible without authentication.
Loading Development Data
When developing, you might not want to add data to the database manually every time.
We can create a development database loader that will add data only when the profile is set to development.
The @Profile
annotation creates a class only when the given profile is active.
Any method annotated with @PostConstruct
will get called after an instance of the class is created.
@Service
@AllArgsConstructor
@Profile("development")
public class DevDatabaseLoader {
private final CourseRepository courseRepository;
@PostConstruct
public void loadCourses() {
courseRepository.save(
Course.builder()
.name("Introduction to Programming")
.code("CSE1100")
.teacher("Thomas Overklift Vaupel Klein")
.numberOfEcs(5)
.build()
);
courseRepository.save(
Course.builder()
.name("Reasoning and Logic")
.code("CSE1300")
.teacher("Stefan Hugtenburg")
.numberOfEcs(5)
.build()
);
}
}
@Service
@Profile("development")
class DevDatabaseLoader(
val courseRepository: CourseRepository
) {
@PostConstruct
fun loadCourses() {
courseRepository.save(Course(
name = "Introduction to Programming",
code = "CSE1100",
teacher = "Thomas Overklift Vaupel Klein",
numberOfEcs = 5,
))
courseRepository.save(Course(
name = "Reasoning and Logic",
code = "CSE1300",
teacher = "Stefan Hugtenburg",
numberOfEcs = 5,
))
}
}
Testing
There are two ways to test our logic: unit tests and integration tests. In a unit test, we do not start up the Spring application at all. We just call the methods and assert some properties about their results. In an integration test, we do start up the Spring application.
Info
Unit tests run faster and are easier to debug. This is why we want mainly unit tests. Of course, we need integration tests as well, but they should make up the minority of the tests.
In all tests, but especially in integration tests, creating an instance of a test class can be expensive.
To limit the amount of instances created, it might be a good idea to tell JUnit to only create one test class instance per class instead of per test method.
To do this we modify the build.gradle.kts
.
Keep in mind that any state we do not want shared between test methods needs to be reset in a @BeforeEach
method.
// ...
tasks.withType<Test> {
useJUnitPlatform {
systemProperty("junit.jupiter.testinstance.lifecycle.default", "per_class")
}
}
Unit Tests
You should already be familiar with unit tests. We just call methods and assert properties about their results. We typically mock dependencies, as we want to test as little logic as possible at a time.
public class CourseServiceUnitTest {
private final CourseService courseService;
public CourseServiceUnitTest() {
this.courseService = new CourseService(mock(CourseRepository.class));
}
@Test
void mathsCourses() {
Course ip = Course.builder().code("CSE1100").build();
Course calc = Course.builder().code("CSE1200").build();
Course co = Course.builder().code("CSE1400").build();
assertThat(courseService.filterMathsCourses(List.of(ip, calc, co)))
.containsExactlyInAnyOrder(calc);
}
}
class CourseServiceUnitTest {
val courseService: CourseService = CourseService(mock())
@Test
fun `Maths courses`() {
val ip = Course(code = "CSE1100", name = "IP", teacher = "",
numberOfEcs = 5)
val calc = Course(code = "CSE1200", name = "Calc", teacher = "",
numberOfEcs = 5)
val co = Course(code = "CSE1400", name = "CO", teacher = "",
numberOfEcs = 5)
assertThat(courseService.filterMathsCourses(listOf(ip, calc, co)))
.containsExactlyInAnyOrder(calc);
}
}
Integration Tests
Before we write any integration tests, we should make sure they run with a predictable configuration.
We can create another application.yaml
file in the src/test/resources
folder.
spring:
profiles:
active: test
datasource:
url: jdbc:h2:mem:testdb
username: sa
password:
To indicate to Spring that it should start up an instance of the application for a test class, we use @SpringBootTest
.
@Transactional
is used to run every test method in a transaction that will get rollbacked after the test method, undoing all changes that might have been made to the database.
On the constructor, we need @Autowired
in tests, as otherwise Spring does not know what to do with it.
@Transactional
@SpringBootTest(classes = ExampleApplication.class)
public class CourseServiceTest {
private final CourseRepository courseRepository;
private final CourseService courseService;
@Autowired
public CourseServiceTest(CourseRepository courseRepository,
CourseService courseSerivce) {
this.courseRepository = courseRepository;
this.courseService = courseService;
}
private Course ip, rnl;
@BeforeEach
void setUp() {
ip = courseRepository.save(
Course.builder()
.name("Introduction to Programming")
.code("CSE1100")
.teacher("Thomas Overklift Vaupel Klein")
.numberOfEcs(5)
.build()
);
rnl = courseRepository.save(
Course.builder()
.name("Reasoning and Logic")
.code("CSE1300")
.teacher("Stefan Hugtenburg")
.numberOfEcs(5)
.build()
);
}
@Test
void getAllCourses() {
assertThat(courseService.getAllCourses())
.containsExactlyInAnyOrder(ip, rnl);
}
@Test
void getCourseById() {
assertThat(courseService.getCourseById(ip.getId())).isEqualTo(ip);
}
}
@Transactional
@SpringBootTest(classes = [ExampleApplication::class])
class CourseServiceIntegrationTest @Autowired constructor(
val courseRepository: CourseRepository,
val courseService: CourseService
) {
lateinit var ip: Course
lateinit var rnl: Course
@BeforeEach
fun setUp() {
ip = courseRepository.save(Course(
name = "Introduction to Programming",
code = "CSE1100",
teacher = "Thomas Overklift Vaupel Klein",
numberOfEcs = 5
))
rnl = courseRepository.save(Course(
name = "Reasoning and Logic",
code = "CSE1300",
teacher = "Stefan Hugtenburg",
numberOfEcs = 5
))
}
@Test
fun `Get all courses`() {
assertThat(courseService.getAllCourses())
.containsExactlyInAnyOrder(ip, rnl)
}
@Test
fun `Get course by ID`() {
assertThat(courseService.getCourseById(ip.id!!)).isEqualTo(ip)
}
}
Beans
This application, like any Spring application, makes heavy use of beans.
A bean is a class that has at most one implementation per application instance.
For example, we can have an actual database in a production environment,
and a mocked database in a testing environment.
This can be seen in the DevDatabaseLoader
,
which is a bean that has an implementation if and only if the profile is development
.
Beans can be defined in multiple ways:
- Any class annotated with
@Component
or one of its derivatives (@Service
,@Controller
,@Configuration
) is a bean. - Any interface extending
Repository
is a bean (unless it is annotated with@NoRepositoryBean
). - Any returned value from a method annotated with
@Bean
in a class annotated with@Configuration
is a bean.
This project has the following beans:
ModelMapperConfig
ModelMapperConfig.modelMapper()
CourseController
CourseRepository
CourseService
DevDatabaseLoader