Anyul Led Rivas
Fullstack Developer
@RestController
@RequestMapping("/api/v1/users")
class UserControllerV1 {
@GetMapping("/{id}") public UserV1 get(@PathVariable Long id) { ... }
}
@RestController
@RequestMapping("/api/v2/users")
class UserControllerV2 {
@GetMapping("/{id}") public UserV2 get(@PathVariable Long id) { ... }
}
Ejemplo usando rutas
@RestController
@RequestMapping("/api")
class UsersController {
private final UsersService service;
@GetMapping(value="/users/{id}", produces={
"application/json",
"application/vnd.acme.user.v2+json",
"application/vnd.acme.user.v1+json"
})
public ResponseEntity<?> getUser(
@PathVariable Long id,
@RequestHeader(value="version", required=false) String version) {
var user = service.find(id).orElseThrow(() -> new EntityNotFoundException("User " + id));
if (version != null && version.contains("v-1")) {
return ResponseEntity.ok(toV1(user));
}
return ResponseEntity.ok(toV2(user)); // default behaviour
}
}
Ejemplo usando encabezados
Añade campos, no elimines ni cambies semánticos. Si necesitas romper, sube a v2.
Nunca expongas entidades JPA. Mapea con MapStruct o tu servicio favorito, y mantén DTOs por versión si hace falta: UserV1, UserV2.
record UserDto(
Long id,
String email,
@JsonInclude(JsonInclude.Include.NON_NULL)
String nickname) {
}
Anuncia deprecaciones programáticamente
@Component
class DeprecationHeaderFilter implements Filter {
@Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletResponse http = (HttpServletResponse) res;
chain.doFilter(req, res);
if (((HttpServletRequest) req).getRequestURI().startsWith("/api/v1/")) {
http.addHeader("Deprecation", "true");
http.addHeader("Sunset", "2026-06-30T00:00:00Z");
http.addHeader("Link", "</api/v2/users>; rel=\"successor-version\"");
}
}
}
Usa ProblemDetail (Spring 6+) para respuestas de error predecibles.
@RestControllerAdvice
class GlobalErrors {
@ExceptionHandler(EntityNotFoundException.class)
ResponseEntity<ProblemDetail> notFound(EntityNotFoundException ex) {
var pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
pd.setTitle("User not found");
pd.setDetail(ex.getMessage());
pd.setProperty("error_code", "USER_404");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd);
}
}
record CreateUserReq(
@NotBlank String email,
@Size(max=50) String name
) {}
class UserController{
@PostMapping
ResponseEntity<UserDto> create(@Valid @RequestBody CreateUserReq req) {
// Business logic here
}
}
Que tu servidor y cliente no exploten si aparecen campos nuevos.
@JsonIgnoreProperties(ignoreUnknown = true)
record ClientRequest(
String name,
String lastName,
Double balance,
...) {}
Evita ordenar por campos inestables. Usa createdAt
o IDs monotónicos. Expón nextCursor
en vez de page=123 si el volumen es grande.
@Entity
@Table(name = "users")
class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void prePersist() {
this.createdAt = Instant.now();
}
// getters y setters
}
Si tus IDs crecen de forma predecible (ej. AUTO_INCREMENT
o IDENTITY
), puedes usarlos para paginación estable.
public interface UserRepository extends JpaRepository<User, Long> {
// Dame los usuarios con id mayor que el cursor
List<User> findTop10ByIdGreaterThanOrderByIdAsc(Long cursor);
}
record PageResponse<T>(List<T> data, Object nextCursor) {}
@GetMapping
public PageResponse<UserDto> getUsers(@RequestParam(required = false) Long cursor) {
var users = repo.findTop10ByIdGreaterThanOrderByIdAsc(cursor == null ? 0L : cursor);
Long next = users.isEmpty() ? null : users.get(users.size() - 1).getId();
return new PageResponse<>(users.stream().map(UserDto::from).toList(), next);
}
Para POST sensibles, soporta Idempotency-Key
para que los reintentos no creen duplicados. Menos sorpresas, menos tickets.
@Component
class IdempotencyFilter extends OncePerRequestFilter {
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
// Sólo aplica a POST (o PUT) de endpoints que lo requieran
return !("POST".equalsIgnoreCase(request.getMethod()) &&
request.getRequestURI().startsWith("/api/orders"));
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws IOException, ServletException {
// logica para determinar si filtramos la petición
try {
chain.doFilter(requestWrapper, responseWrapper); // ejecuta el controlador
} finally {
// Guardar respuesta
rec.status = responseWrapper.getStatus();
rec.body = new String(responseWrapper.getContentAsByteArray(), java.nio.charset.StandardCharsets.UTF_8);
// guarda headers interesantes
Map<String,String> headers = new HashMap<>();
var location = responseWrapper.getHeader("Location");
if (location != null) headers.put("Location", location);
rec.headersJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(headers);
rec.completed = true;
repo.save(rec);
// Copiar el body al response real
responseWrapper.copyBodyToResponse();
res.setHeader("Idempotency-Key", key);
}
}
}
Respuestas con ETag + If-None-Match
para caching y actualizaciones condicionales. No es glamuroso, pero evita lecturas inconsistentes sin tener que romper endpoints.
openapi-diff
en CI.<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
Usa Spring Cloud Contract para verificar que sigues cumpliendo lo prometido a clientes.
Prueba payloads con JsonContent y snapshots.
@WebMvcTest(UserControllerV1.class)
class UserV1JsonTest {
@Autowired JacksonTester<UserV1> json;
@Test void shapeIsStable() throws Exception {
var u = new UserV1(1L, "a@b.com", null);
assertThat(json.write(u)).isStrictlyEqualToJson("expected/user-v1.json");
}
}
Spring Cloud Gateway puede rutear /v1/**
a un backend y /v2/**
a otro.
También añade y traduce encabezados de compatibilidad. Ideal cuando quieres matar v. 1 sin cirugía mayor.
Analiza las diferencias entre dos especificaciones (v.3)
Flyway/Liquibase con el patrón “expand/contract”: primero añade columnas nuevas, luego escribe doble, después migra lecturas, y al final limpia lo viejo. Tu API ni se entera.
<dependencies>
...
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
create sequence task_seq increment by 50;
create table task
(
task_id bigint not null,
username varchar(255) ,
description varchar(255) not null,
creation_date timestamp with time zone not null,
due_date date,
primary key (task_id)
);
Las migraciones versionadas siempre empiezan con la letra V, en mayúscula
Ejemplo:
src/main/resources/db/migration/V1__create_task.sql
ALTER TABLE task ADD COLUMN full_name VARCHAR(255);
Actualizamos la tabla agregando campos sin borrar los antiguos
Ejemplo:
src/main/resources/db/migration/V2__alter_task.sql
UPDATE task SET full_name = username;
Las migraciones versionadas siempre empiezan con la letra V, en mayúscula
Ejemplo:
src/main/resources/db/migration/V3__update_task.sql
ALTER TABLE task DROP COLUMN username;
Las migraciones versionadas siempre empiezan con la letra V, en mayúscula
Ejemplo:
src/main/resources/db/migration/V4__alter_task.sql
No renombres campos ni cambies tipos en una versión; añade nuevos y documenta.
No reutilices códigos de error con significados distintos.
Documenta la ventana y plan de eliminación de endpoints obsoletos.
Automatiza chequeos de contrato en CI. Si no está en CI, no existe.
Mantén ejemplos de requests/responses
y actualízalos en la doc. Los clientes los copian sin leer, asúmelo.
By Anyul Led Rivas