Saltar al contenido principal

Primer endpoint

Código Completo
El resultado final del código desarrollado en este documento se puede encontrar en el monorepo de GitHub springboot-demo-projects, bajo el tag first-endpoint.

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
│ │ │ │ ├── FilmRestMapper.java
│ │ │ │ └── FilmRestController.java
│ │ │ └── domain
│ │ │ ├── model
│ │ │ │ └── Film.java
│ │ │ └── port
│ │ │ └── in
│ │ │ ├── FindByIdPortIn.java
│ │ │ └── FindByIdPortInImpl.java
│ │ └── ...
│ └── resources
│ └── ...
└── test
└── ...

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.

Configurar el formateador de código

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

Luego actualiza build.gradle (o build.gradle.kts) para registrar el plugin Spotless y conectarlo al ciclo de vida del build:

build.gradle
plugins {
// ...
id 'com.diffplug.spotless' version '8.1.0'
}
// ...
spotless {
java {
target 'src/*/java/**/*.java'
googleJavaFormat()
removeUnusedImports()
cleanthat()
formatAnnotations()
}
groovyGradle {
target '*.gradle'
greclipse().configFile('greclipse.properties')
}
}

tasks.named("build") {
dependsOn 'spotlessApply'
dependsOn 'spotlessGroovyGradleApply'
}

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;
}

Puerto primario

Un puerto es un contrato. El puerto primario (FindByIdPortIn) 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/FindByIdPortIn.java
package dev.pollito.spring_java.sakila.film.domain.port.in;

import dev.pollito.spring_java.sakila.film.domain.model.Film;

public interface FindByIdPortIn {
Film findById(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

FindByIdPortInImpl es la implementación de ese contrato. Por ahora, devuelve una película hardcodeada sin importar el ID proporcionado. La implementación real con soporte de base de datos llega en un documento posterior.

java/dev/pollito/spring_java/sakila/film/domain/port/in/FindByIdPortInImpl.java
package dev.pollito.spring_java.sakila.film.domain.port.in;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import org.springframework.stereotype.Service;

@Service
public class FindByIdPortInImpl implements FindByIdPortIn {
@Override
public Film findById(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();
}
}

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;
}

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 convert(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();
}
}

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.FindByIdPortIn;
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 FindByIdPortIn findByIdPortIn;
private final FilmRestMapper mapper;

@GetMapping("/{id}")
public FilmResponse findById(@PathVariable Integer id) {
return mapper.convert(findByIdPortIn.findById(id));
}
}

El controlador depende únicamente de FindByIdPortIn (la interfaz) y FilmRestMapper. No tiene conocimiento de cómo está implementado FindByIdPortIn. 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:

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"
}

¿Qué sigue?

La arquitectura está en su lugar, pero FindByIdPortInImpl devuelve la misma película hardcodeada para cada ID. El siguiente paso es reemplazarla con una consulta real a la base de datos, lo que implica conectar un puerto secundario, un repositorio JPA y un mapper de entidad a dominio. La capa de dominio no cambiará en absoluto.