PCSalt
YouTube GitHub
Back to Spring Boot
Spring Boot · 1 min read

Spring Boot 4 Starter — Project Setup, Structure & First API

Set up a Spring Boot 4 project from scratch — project structure, REST controller, configuration, profiles, and your first working API endpoint.


Spring Boot 4.0 was released in November 2025 alongside Spring Framework 7. It’s a major release — modularized jars, JSpecify null safety, API versioning support, OpenTelemetry starter, and Gradle 9 support.

This post gets you from zero to a running API.

Create the project

Use Spring Initializr or set up manually.

build.gradle.kts

plugins {
    kotlin("jvm") version "2.1.0"
    kotlin("plugin.spring") version "2.1.0"
    id("org.springframework.boot") version "4.0.3"
    id("io.spring.dependency-management") version "1.1.7"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(21))
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<Test> {
    useJUnitPlatform()
}

Application entry point

package com.example.demo

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class DemoApplication

fun main(args: Array<String>) {
    runApplication<DemoApplication>(*args)
}

Run it:

./gradlew bootRun

You’ll see:

Started DemoApplication in 1.2 seconds

Project structure

src/main/kotlin/com/example/demo/
├── DemoApplication.kt
├── controller/
│   └── UserController.kt
├── service/
│   └── UserService.kt
├── repository/
│   └── UserRepository.kt
├── model/
│   ├── User.kt
│   └── dto/
│       ├── CreateUserRequest.kt
│       └── UserResponse.kt
└── config/
    └── AppConfig.kt

src/main/resources/
├── application.yml
├── application-dev.yml
└── application-prod.yml

src/test/kotlin/com/example/demo/
├── controller/
│   └── UserControllerTest.kt
└── service/
    └── UserServiceTest.kt

Keep it flat until it needs to grow. Don’t create 20 packages for a 5-endpoint API.

Your first REST controller

Model

package com.example.demo.model

import java.time.Instant
import java.util.UUID

data class User(
    val id: String = UUID.randomUUID().toString(),
    val name: String,
    val email: String,
    val createdAt: Instant = Instant.now()
)

DTOs

package com.example.demo.model.dto

data class CreateUserRequest(
    val name: String,
    val email: String
)

data class UserResponse(
    val id: String,
    val name: String,
    val email: String,
    val createdAt: String
)

Service

package com.example.demo.service

import com.example.demo.model.User
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap

@Service
class UserService {

    private val users = ConcurrentHashMap<String, User>()

    fun getAll(): List<User> = users.values.toList()

    fun getById(id: String): User? = users[id]

    fun create(name: String, email: String): User {
        val user = User(name = name, email = email)
        users[user.id] = user
        return user
    }

    fun delete(id: String): Boolean = users.remove(id) != null
}

Controller

package com.example.demo.controller

import com.example.demo.model.dto.CreateUserRequest
import com.example.demo.model.dto.UserResponse
import com.example.demo.service.UserService
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/v1/users")
class UserController(private val userService: UserService) {

    @GetMapping
    fun getAll(): List<UserResponse> {
        return userService.getAll().map { it.toResponse() }
    }

    @GetMapping("/{id}")
    fun getById(@PathVariable id: String): ResponseEntity<UserResponse> {
        val user = userService.getById(id) ?: return ResponseEntity.notFound().build()
        return ResponseEntity.ok(user.toResponse())
    }

    @PostMapping
    fun create(@RequestBody request: CreateUserRequest): ResponseEntity<UserResponse> {
        val user = userService.create(request.name, request.email)
        return ResponseEntity.status(HttpStatus.CREATED).body(user.toResponse())
    }

    @DeleteMapping("/{id}")
    fun delete(@PathVariable id: String): ResponseEntity<Void> {
        return if (userService.delete(id)) {
            ResponseEntity.noContent().build()
        } else {
            ResponseEntity.notFound().build()
        }
    }

    private fun com.example.demo.model.User.toResponse() = UserResponse(
        id = id,
        name = name,
        email = email,
        createdAt = createdAt.toString()
    )
}

Configuration

application.yml

server:
  port: 8080

spring:
  application:
    name: demo-api
  jackson:
    serialization:
      write-dates-as-timestamps: false
    default-property-inclusion: non_null

logging:
  level:
    com.example.demo: DEBUG
    org.springframework.web: INFO

Profiles

application-dev.yml:

server:
  port: 8080

logging:
  level:
    com.example.demo: DEBUG
    org.springframework.web: DEBUG

application-prod.yml:

server:
  port: ${PORT:8080}

logging:
  level:
    com.example.demo: INFO
    org.springframework.web: WARN

Activate with:

# Dev
./gradlew bootRun --args='--spring.profiles.active=dev'

# Prod
java -jar app.jar --spring.profiles.active=prod

Error handling

package com.example.demo.config

import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

data class ErrorResponse(
    val status: Int,
    val message: String
)

class NotFoundException(message: String) : RuntimeException(message)
class BadRequestException(message: String) : RuntimeException(message)

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(NotFoundException::class)
    fun handleNotFound(e: NotFoundException): ResponseEntity<ErrorResponse> {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse(404, e.message ?: "Not found"))
    }

    @ExceptionHandler(BadRequestException::class)
    fun handleBadRequest(e: BadRequestException): ResponseEntity<ErrorResponse> {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(ErrorResponse(400, e.message ?: "Bad request"))
    }

    @ExceptionHandler(Exception::class)
    fun handleGeneral(e: Exception): ResponseEntity<ErrorResponse> {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse(500, "Internal server error"))
    }
}

Testing

package com.example.demo.controller

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post
import kotlin.test.Test

@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {

    @Autowired
    lateinit var mockMvc: MockMvc

    @Test
    fun `GET users returns empty list initially`() {
        mockMvc.get("/api/v1/users")
            .andExpect {
                status { isOk() }
                content { json("[]") }
            }
    }

    @Test
    fun `POST creates user and returns 201`() {
        mockMvc.post("/api/v1/users") {
            contentType = MediaType.APPLICATION_JSON
            content = """{"name": "Alice", "email": "[email protected]"}"""
        }.andExpect {
            status { isCreated() }
            jsonPath("$.name") { value("Alice") }
            jsonPath("$.email") { value("[email protected]") }
            jsonPath("$.id") { isNotEmpty() }
        }
    }

    @Test
    fun `GET unknown user returns 404`() {
        mockMvc.get("/api/v1/users/nonexistent")
            .andExpect {
                status { isNotFound() }
            }
    }
}

Run tests:

./gradlew test

What’s new in Spring Boot 4

FeatureWhat changed
ModularizationSmaller, focused starter jars
JSpecify null safetyCompile-time null checks
API versioningBuilt-in URL/header-based versioning
OpenTelemetryNew spring-boot-starter-opentelemetry
Gradle 9Full support
Java 25First-class support (Java 17 minimum)
HTTP service clientsDeclarative HTTP client interfaces

Summary

You now have a running Spring Boot 4 API with:

  • REST controller with CRUD endpoints
  • Service layer with in-memory storage
  • DTOs for request/response separation
  • Global error handling
  • Profile-based configuration
  • Integration tests

Next posts in the series will add: validation, JPA + PostgreSQL, JWT authentication, testing with Testcontainers, Kafka integration, and production monitoring.