The application now starts and responds with a 404 on every route. Time to give it something real to do. This document builds a single endpoint, GET /api/films/{id}, following hexagonal architecture from day one. The data is hardcoded for now; the goal is to establish the structure that the rest of the application will grow into.
The endpoint returns a film that looks like this:
{
"id" : 42 ,
"title" : "ACADEMY DINOSAUR" ,
"description" : "A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies" ,
"releaseYear" : 2006 ,
"rating" : "PG" ,
"length" : 86 ,
"language" : "English"
}
New files
File Tree
.
├── build.gradle
├── greclipse.properties
├── ...
└── src
├── main
│ ├── java
│ │ └── dev/pollito/spring_java
│ │ ├── sakila
│ │ │ └── film
│ │ │ ├── adapter
│ │ │ │ └── in/rest
│ │ │ │ ├── dto
│ │ │ │ │ └── FilmResponse.java
│ │ │ │ ├── FilmRestController.java
│ │ │ │ └── FilmRestMapper.java
│ │ │ └── domain
│ │ │ ├── model
│ │ │ │ └── Film.java
│ │ │ ├── port/in
│ │ │ │ └── FilmUseCases.java
│ │ │ └── service
│ │ │ └── FilmUseCasesImpl.java
│ │ └── ...
│ └── ...
└── ...
Expand(18 more lines) File Tree
.
├── build.gradle.kts
├── ...
└── src
├── main
│ ├── kotlin
│ │ └── dev/pollito/spring_kotlin
│ │ ├── sakila
│ │ │ └── film
│ │ │ ├── adapter
│ │ │ │ └── in/rest
│ │ │ │ ├── dto
│ │ │ │ │ └── FilmResponse.kt
│ │ │ │ ├── FilmRestController.kt
│ │ │ │ └── FilmRestMapper.kt
│ │ │ └── domain
│ │ │ ├── model
│ │ │ │ └── Film.kt
│ │ │ ├── port/in
│ │ │ │ └── FilmUseCases.kt
│ │ │ └── service
│ │ │ └── FilmUseCasesImpl.kt
│ │ └── ...
│ └── ...
└── ...
Expand(16 more lines) File Tree
.
├── build.gradle
├── greclipse.properties
├── ...
└── src
├── main
│ ├── groovy
│ │ └── dev/pollito/spring_groovy
│ │ ├── sakila
│ │ │ └── film
│ │ │ ├── adapter
│ │ │ │ └── in/rest
│ │ │ │ ├── dto
│ │ │ │ │ └── FilmResponse.groovy
│ │ │ │ ├── FilmRestController.groovy
│ │ │ │ └── FilmRestMapper.groovy
│ │ │ └── domain
│ │ │ ├── model
│ │ │ │ └── Film.groovy
│ │ │ ├── port/in
│ │ │ │ └── FilmUseCases.groovy
│ │ │ └── service
│ │ │ └── FilmUseCasesImpl.groovy
│ │ └── ...
│ └── ...
└── ...
Expand(18 more lines)
Everything new lives under the film feature. The domain layer defines the model and the port interface. The adapter layer handles HTTP concerns. Nothing in the domain knows that HTTP exists.
Before writing any code, set up Spotless to enforce consistent formatting across the codebase. Running ./gradlew build will automatically format your code before compiling.
Java and Groovy modules also need a greclipse.properties file to configure indentation:
greclipse.properties
org.eclipse.jdt.core.formatter.tabulation.char=space
org.eclipse.jdt.core.formatter.tabulation.size=2
org.eclipse.jdt.core.formatter.indentation.size=2
greclipse.properties
org.eclipse.jdt.core.formatter.tabulation.char=space
org.eclipse.jdt.core.formatter.tabulation.size=2
org.eclipse.jdt.core.formatter.indentation.size=2
Then update build.gradle (or build.gradle.kts) to register the Spotless plugin and wire it into the build lifecycle:
build.gradle (plugins block)
id 'com.diffplug.spotless' version '8.2.1'
build.gradle (new spotless block)
spotless {
java {
target 'src/*/java/**/*.java'
googleJavaFormat ( )
removeUnusedImports ( )
cleanthat ( )
formatAnnotations ( )
}
groovyGradle {
target '*.gradle'
greclipse ( ) . configFile ( 'greclipse.properties' )
}
}
tasks . named ( "build" ) {
dependsOn 'spotlessApply'
dependsOn 'spotlessGroovyGradleApply'
}
Expand(4 more lines) build.gradle.kts (plugins block)
id ( "com.diffplug.spotless" ) version "8.2.1"
build.gradle.kts (new SpotlessExtension block)
configure < com . diffplug . gradle . spotless . SpotlessExtension > {
kotlin { ktfmt ( ) }
kotlinGradle {
target ( "*.gradle.kts" )
ktfmt ( )
}
}
tasks . named ( "build" ) {
dependsOn ( "spotlessKotlinApply" )
dependsOn ( "spotlessKotlinGradleApply" )
}
build.gradle (plugins block)
id 'com.diffplug.spotless' version '8.2.1'
build.gradle (new spotless block)
spotless {
groovy {
importOrder ( )
removeSemicolons ( )
greclipse ( ) . configFile ( 'greclipse.properties' )
excludeJava ( )
}
groovyGradle {
target '*.gradle'
greclipse ( ) . configFile ( 'greclipse.properties' )
}
}
tasks . named ( "build" ) {
dependsOn 'spotlessGroovyApply'
dependsOn 'spotlessGroovyGradleApply'
}
Expand(3 more lines)
With this in place, formatting is no longer a manual step or a code review concern. Every build produces consistently formatted code.
Domain layer
The domain layer is the heart of the application. It owns the business model and declares what operations are available, without knowing anything about databases, HTTP, or any other infrastructure detail.
Domain model
Film is the central domain model. It represents a film as the application understands it: not as a database row, not as an HTTP response, just a plain data object.
java/dev/pollito/spring_java/sakila/film/domain/model/Film.java
package dev . pollito . spring_java . sakila . film . domain . model ;
import static lombok . AccessLevel . * ;
import lombok . * ;
import lombok . experimental . FieldDefaults ;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@FieldDefaults ( level = PRIVATE )
public class Film {
Integer id ;
String title ;
String description ;
Integer releaseYear ;
String rating ;
Integer length ;
String language ;
}
Expand(7 more lines) kotlin/dev/pollito/spring_kotlin/sakila/film/domain/model/Film.kt
package dev . pollito . spring_kotlin . sakila . film . domain . model
data class Film (
val id : Int ,
val title : String ,
val description : String ,
val releaseYear : Int ,
val rating : String ,
val length : Int ,
val language : String ,
)
groovy/dev/pollito/spring_groovy/sakila/film/domain/model/Film.groovy
package dev . pollito . spring_groovy . sakila . film . domain . model
import groovy . transform . Canonical
import groovy . transform . CompileStatic
@Canonical
@CompileStatic
class Film {
Integer id
String title
String description
Integer releaseYear
String rating
Integer length
String language
}
Expand(2 more lines)
Primary port
A port is a contract. The primary port (FilmUseCases) declares what the domain exposes to the outside world: given an ID, return a Film.
java/dev/pollito/spring_java/sakila/film/domain/port/in/FilmUseCases.java
package dev . pollito . spring_java . sakila . film . domain . port . in ;
import dev . pollito . spring_java . sakila . film . domain . model . Film ;
public interface FilmUseCases {
Film getFilm ( Integer id ) ;
}
kotlin/dev/pollito/spring_kotlin/sakila/film/domain/port/in/FilmUseCases.kt
package dev . pollito . spring_kotlin . sakila . film . domain . port . ` in `
import dev . pollito . spring_kotlin . sakila . film . domain . model . Film
interface FilmUseCases {
fun getFilm ( id : Int ) : Film
}
groovy/dev/pollito/spring_groovy/sakila/film/domain/port/in/FilmUseCases.groovy
package dev . pollito . spring_groovy . sakila . film . domain . port . in
import dev . pollito . spring_groovy . sakila . film . domain . model . Film
interface FilmUseCases {
Film getFilm ( Integer id )
}
The interface lives in the domain layer. The REST controller will depend on this interface, not on any concrete implementation. That indirection is what makes the architecture flexible.
Primary port implementation
FilmUseCasesImpl is the implementation of that contract. For now, it returns a hardcoded film regardless of the ID provided.
java/dev/pollito/spring_java/sakila/film/domain/service/FilmUseCasesImpl.java
package dev . pollito . spring_java . sakila . film . domain . service ;
import dev . pollito . spring_java . sakila . film . domain . model . Film ;
import dev . pollito . spring_java . sakila . film . domain . port . in . FilmUseCases ;
import org . springframework . stereotype . Service ;
@Service
public class FilmUseCasesImpl implements FilmUseCases {
@Override
public Film getFilm ( Integer id ) {
return Film . builder ( )
. id ( id )
. title ( "ACADEMY DINOSAUR" )
. description (
"A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies" )
. releaseYear ( 2006 )
. rating ( "PG" )
. length ( 86 )
. language ( "English" )
. build ( ) ;
}
}
Expand(8 more lines) kotlin/dev/pollito/spring_kotlin/sakila/film/domain/service/FilmUseCasesImpl.kt
package dev . pollito . spring_kotlin . sakila . film . domain . service
import dev . pollito . spring_kotlin . sakila . film . domain . model . Film
import dev . pollito . spring_kotlin . sakila . film . domain . port . ` in ` . FilmUseCases
import org . springframework . stereotype . Service
@Service
class FilmUseCasesImpl : FilmUseCases {
override fun getFilm ( id : Int ) : Film {
return Film (
id = id ,
title = "ACADEMY DINOSAUR" ,
description =
"A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies" ,
releaseYear = 2006 ,
rating = "PG" ,
length = 86 ,
language = "English" ,
)
}
}
Expand(7 more lines) groovy/dev/pollito/spring_groovy/sakila/film/domain/service/FilmUseCasesImpl.groovy
package dev . pollito . spring_groovy . sakila . film . domain . service
import dev . pollito . spring_groovy . sakila . film . domain . model . Film
import dev . pollito . spring_groovy . sakila . film . domain . port . in . FilmUseCases
import groovy . transform . CompileStatic
import org . springframework . stereotype . Service
@Service
@CompileStatic
class FilmUseCasesImpl implements FilmUseCases {
@Override
Film getFilm ( Integer id ) {
new Film (
id : id ,
title : "ACADEMY DINOSAUR" ,
description : "A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies" ,
releaseYear : 2006 ,
rating : "PG" ,
length : 86 ,
language : "English"
)
}
}
Expand(9 more lines)
The hardcoded data is intentional. It lets you verify that the full request-response cycle works end-to-end before introducing any persistence complexity.
REST adapter
The adapter layer translates between the domain and the outside world. For an inbound REST adapter, that means receiving HTTP requests, calling the domain, and returning HTTP responses.
Response DTO
FilmResponse is what the API exposes over the wire. It mirrors Film field-for-field at this stage, but keeping them separate matters: the domain model can evolve independently of the API contract.
java/dev/pollito/spring_java/sakila/film/adapter/in/rest/dto/FilmResponse.java
package dev . pollito . spring_java . sakila . film . adapter . in . rest . dto ;
import static lombok . AccessLevel . * ;
import lombok . * ;
import lombok . experimental . FieldDefaults ;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@FieldDefaults ( level = PRIVATE )
public class FilmResponse {
Integer id ;
String title ;
String description ;
Integer releaseYear ;
String rating ;
Integer length ;
String language ;
}
Expand(7 more lines) kotlin/dev/pollito/spring_kotlin/sakila/film/adapter/in/rest/dto/FilmResponse.kt
package dev . pollito . spring_kotlin . sakila . film . adapter . ` in ` . rest . dto
data class FilmResponse (
val id : Int ,
val title : String ,
val description : String ,
val releaseYear : Int ,
val rating : String ,
val length : Int ,
val language : String ,
)
groovy/dev/pollito/spring_groovy/sakila/film/adapter/in/rest/dto/FilmResponse.groovy
package dev . pollito . spring_groovy . sakila . film . adapter . in . rest . dto
import groovy . transform . Canonical
import groovy . transform . CompileStatic
@Canonical
@CompileStatic
class FilmResponse {
Integer id
String title
String description
Integer releaseYear
String rating
Integer length
String language
}
Expand(2 more lines)
Mapper
FilmRestMapper maps a Film domain model into a FilmResponse DTO. This translation responsibility belongs here, not in the controller or in the domain.
java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestMapper.java
package dev . pollito . spring_java . sakila . film . adapter . in . rest ;
import static java . util . Objects . isNull ;
import dev . pollito . spring_java . sakila . film . adapter . in . rest . dto . FilmResponse ;
import dev . pollito . spring_java . sakila . film . domain . model . Film ;
import org . springframework . stereotype . Component ;
@Component
public class FilmRestMapper {
public FilmResponse map ( Film source ) {
if ( isNull ( source ) ) {
return null ;
}
return FilmResponse . builder ( )
. id ( source . getId ( ) )
. title ( source . getTitle ( ) )
. description ( source . getDescription ( ) )
. releaseYear ( source . getReleaseYear ( ) )
. rating ( source . getRating ( ) )
. length ( source . getLength ( ) )
. language ( source . getLanguage ( ) )
. build ( ) ;
}
}
Expand(11 more lines) kotlin/dev/pollito/spring_kotlin/sakila/film/adapter/in/rest/FilmRestMapper.kt
package dev . pollito . spring_kotlin . sakila . film . adapter . ` in ` . rest
import dev . pollito . spring_kotlin . sakila . film . adapter . ` in ` . rest . dto . FilmResponse
import dev . pollito . spring_kotlin . sakila . film . domain . model . Film
import org . springframework . stereotype . Component
@Component
class FilmRestMapper {
fun map ( source : Film ? ) : FilmResponse ? {
if ( source == null ) {
return null
}
return FilmResponse (
id = source . id ,
title = source . title ,
description = source . description ,
releaseYear = source . releaseYear ,
rating = source . rating ,
length = source . length ,
language = source . language ,
)
}
}
Expand(9 more lines) groovy/dev/pollito/spring_groovy/sakila/film/adapter/in/rest/FilmRestMapper.groovy
package dev . pollito . spring_groovy . sakila . film . adapter . in . rest
import dev . pollito . spring_groovy . sakila . film . adapter . in . rest . dto . FilmResponse
import dev . pollito . spring_groovy . sakila . film . domain . model . Film
import groovy . transform . CompileDynamic
import groovy . transform . CompileStatic
@CompileStatic
final class FilmRestMapper {
private FilmRestMapper ( ) { }
@CompileDynamic
static FilmResponse map ( Film source ) {
source ? new FilmResponse (
source . properties . findAll {
it . key != 'class'
}
) : null
}
}
Expand(6 more lines)
Controller
FilmRestController ties it all together. It handles the GET /api/films/{id} route, delegates to the primary port, and maps the result to a response DTO.
java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestController.java
package dev . pollito . spring_java . sakila . film . adapter . in . rest ;
import dev . pollito . spring_java . sakila . film . adapter . in . rest . dto . FilmResponse ;
import dev . pollito . spring_java . sakila . film . domain . port . in . FilmUseCases ;
import lombok . RequiredArgsConstructor ;
import org . springframework . web . bind . annotation . GetMapping ;
import org . springframework . web . bind . annotation . PathVariable ;
import org . springframework . web . bind . annotation . RequestMapping ;
import org . springframework . web . bind . annotation . RestController ;
@RestController
@RequestMapping ( "/api/films" )
@RequiredArgsConstructor
public class FilmRestController {
private final FilmUseCases useCases ;
private final FilmRestMapper mapper ;
@GetMapping ( "/{id}" )
public FilmResponse getFilm ( @PathVariable Integer id ) {
return mapper . map ( useCases . getFilm ( id ) ) ;
}
}
Expand(8 more lines) kotlin/dev/pollito/spring_kotlin/sakila/film/adapter/in/rest/FilmRestController.kt
package dev . pollito . spring_kotlin . sakila . film . adapter . ` in ` . rest
import dev . pollito . spring_kotlin . sakila . film . adapter . ` in ` . rest . dto . FilmResponse
import dev . pollito . spring_kotlin . sakila . film . domain . port . ` in ` . FilmUseCases
import org . springframework . web . bind . annotation . GetMapping
import org . springframework . web . bind . annotation . PathVariable
import org . springframework . web . bind . annotation . RequestMapping
import org . springframework . web . bind . annotation . RestController
@RestController
@RequestMapping ( "/api/films" )
class FilmRestController (
private val useCases : FilmUseCases ,
private val mapper : FilmRestMapper ,
) {
@GetMapping ( "/{id}" )
fun getFilm ( @PathVariable id : Int ) : FilmResponse ? {
return mapper . map ( useCases . getFilm ( id ) )
}
}
Expand(6 more lines) groovy/dev/pollito/spring_groovy/sakila/film/adapter/in/rest/FilmRestController.groovy
package dev . pollito . spring_groovy . sakila . film . adapter . in . rest
import static FilmRestMapper . map
import dev . pollito . spring_groovy . sakila . film . adapter . in . rest . dto . FilmResponse
import dev . pollito . spring_groovy . sakila . film . domain . port . in . FilmUseCases
import groovy . transform . CompileStatic
import org . springframework . web . bind . annotation . GetMapping
import org . springframework . web . bind . annotation . PathVariable
import org . springframework . web . bind . annotation . RequestMapping
import org . springframework . web . bind . annotation . RestController
@RestController
@RequestMapping ( "/api/films" )
@CompileStatic
class FilmRestController {
FilmUseCases useCases
FilmRestController ( FilmUseCases useCases ) {
this . useCases = useCases
}
@GetMapping ( "/{id}" )
FilmResponse getFilm ( @PathVariable ( "id" ) Integer id ) {
map ( useCases . getFilm ( id ) )
}
}
Expand(13 more lines)
The controller depends only on FilmUseCases (the interface) and FilmRestMapper. It has no knowledge of how FilmUseCases is implemented. Today it's hardcoded data; later it will be a database query. The controller won't change either way.
The complete flow
Here's the full request lifecycle:
+ - Reset Full Screen Scroll to zoom • Drag corner to resize
Build and run the application, then hit the endpoint:
Terminal
curl -s http://localhost:8080/api/films/42 | jq
{
"id": 42,
"title": "ACADEMY DINOSAUR",
"description": "A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies",
"releaseYear": 2006,
"rating": "PG",
"lengthMinutes": 86,
"language": "English"
}