Skip to content

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:

build.gradle.kts
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.

application.yaml
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:

src/main/java/ExampleApplication.java
@SpringBootApplication
public class ExampleApplication {

    public static void main(String[] args) {
        SpringApplication.run(ExampleApplication.class, args);
    }

}
src/main/kotlin/ExampleApplication.kt
@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.

src/main/java/model/Course.java
@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;

}
src/main/kotlin/model/Course.kt
@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.

src/main/java/repository/CourseRepository.java
public interface CourseRepository extends JpaRepository<Course, Long> {
    Optional<Course> findByCode(String code);
}
src/main/kotlin/repository/CourseRepository.kt
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.

src/main/java/service/CourseService.java
@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);
    }

}
src/main/kotlin/service/CourseService.kt
@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:

src/main/java/dto/CourseViewDTO.java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CourseViewDTO {
    private Long id;
    private String name;
    private String code;
    private Integer numberOfEcs;
    private String teacher;
}
src/main/java/dto/CourseCreateDTO.java
@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;
}

src/main/kotlin/dto/CourseViewDTO.kt
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)
    }
}
src/main/kotlin/dto/CourseCreateDTO.kt
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:

src/main/java/config/ModelMapperConfig.java
@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 classes 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.

src/main/kotlin/config/ModelMapperConfig.kt
@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.

src/main/java/controller/CourseController.java
@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();
    }

}
src/main/kotlin/controller/CourseController.kt
@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.

src/main/java/DevDatabaseLoader.java
@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()
        );
    }

}
src/main/kotlin/DevDatabaseLoader.kt
@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.

build.gradle.kts
// ...

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.

src/test/java/service/CourseServiceUnitTest.java
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);
    }

}
src/test/kotlin/CourseServiceUnitTest.kt
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.

src/test/resources/application.yaml
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.

src/test/java/service/CourseServiceUnitTest.java
@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);
    }

}
src/test/kotlin/CourseServiceIntegrationTest.kt
@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