La aplicación ahora inicia y responde con un 404 en cada ruta. Es hora de darle algo real que hacer. Este documento construye un único endpoint, GET /api/films/{id}, siguiendo arquitectura hexagonal desde el primer día. Los datos están hardcodeados por ahora; el objetivo es establecer la estructura en la que crecerá el resto de la aplicación.
El endpoint devuelve una película con este aspecto:
{
"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"
}
Nuevos archivos
Archivos a Crear/Modificar
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)
Todo lo nuevo vive bajo el feature film. La capa de dominio define el modelo y la interfaz del puerto. La capa adaptadora maneja las preocupaciones HTTP. Nada en el dominio sabe que HTTP existe.
Antes de escribir cualquier código, configura Spotless para aplicar un formato consistente en todo el proyecto. Ejecutar ./gradlew build formateará automáticamente tu código antes de compilar.
Los módulos Java y Groovy también necesitan un archivo greclipse.properties para configurar la indentación:
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
Luego actualiza build.gradle (o build.gradle.kts) para registrar el plugin Spotless y conectarlo al ciclo de vida del build:
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)
Con esto en su lugar, el formateo ya no es un paso manual ni una preocupación de revisión de código. Cada build produce código con formato consistente.
Capa de dominio
La capa de dominio es el corazón de la aplicación. Es dueña del modelo de negocio y declara qué operaciones están disponibles, sin saber nada sobre bases de datos, HTTP ni ningún otro detalle de infraestructura.
Modelo de dominio
Film es el modelo de dominio central. Representa una película tal como la entiende la aplicación: no como una fila de base de datos, no como una respuesta HTTP, sino simplemente un objeto de datos plano.
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)
Puerto primario
Un puerto es un contrato. El puerto primario (FilmUseCases) declara lo que el dominio expone al mundo exterior: dado un ID, devuelve un 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 )
}
La interfaz vive en la capa de dominio. El controlador REST dependerá de esta interfaz, no de ninguna implementación concreta. Esa indirección es lo que hace que la arquitectura sea flexible.
Implementación del puerto primario
FilmUseCasesImpl es la implementación de ese contrato. Por ahora, devuelve una película hardcodeada sin importar el ID proporcionado.
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)
Los datos hardcodeados son intencionales. Permiten verificar que el ciclo completo de solicitud-respuesta funciona de extremo a extremo antes de introducir cualquier complejidad de persistencia.
Adaptador REST
La capa adaptadora traduce entre el dominio y el mundo exterior. Para un adaptador REST de entrada, eso significa recibir solicitudes HTTP, llamar al dominio y devolver respuestas HTTP.
DTO de respuesta
FilmResponse es lo que la API expone a través de la red. En esta etapa refleja Film campo por campo, pero mantenerlos separados importa: el modelo de dominio puede evolucionar independientemente del contrato de la API.
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 convierte un modelo de dominio Film en un DTO FilmResponse. Esta responsabilidad de traducción pertenece aquí, no en el controlador ni en el dominio.
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)
Controlador
FilmRestController lo une todo. Maneja la ruta GET /api/films/{id}, delega al puerto primario y mapea el resultado a un DTO de respuesta.
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)
El controlador depende únicamente de FilmUseCases (la interfaz) y FilmRestMapper. No tiene conocimiento de cómo está implementado FilmUseCases. Hoy son datos hardcodeados; más adelante será una consulta a base de datos. El controlador no cambiará en ninguno de los dos casos.
El flujo completo
Aquí está el ciclo de vida completo de una solicitud:
+ - Reset Full Screen Scroll to zoom • Drag corner to resize
Compila y ejecuta la aplicación, luego llama al 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"
}