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
| Feature | What changed |
|---|---|
| Modularization | Smaller, focused starter jars |
| JSpecify null safety | Compile-time null checks |
| API versioning | Built-in URL/header-based versioning |
| OpenTelemetry | New spring-boot-starter-opentelemetry |
| Gradle 9 | Full support |
| Java 25 | First-class support (Java 17 minimum) |
| HTTP service clients | Declarative 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.