Versionado de APIs

¿Por qué es difícil mantener APIs estables?

  • Requisitos cambiantes del negocio
  • Evolución tecnológica
  • Errores de diseño inicial
  • Expectativas de los consumidores
  • Escalabilidad y rendimiento
  • Compatibilidad retroactiva
  • Falta de gobernanza y documentación
  • Cambios en regulaciones o estándares

Mejores prácticas de versionado de APIs con Spring Boot

Versiona en el contrato usando rutas o encabezados de petición

@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

 

Cambios solo aditivos en versiones menores

 

 

Añade campos, no elimines ni cambies semánticos. Si necesitas romper, sube a v2.

 

 

Desacopla modelos internos de DTOs públicos

 

 

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) {

}

 

Cabeceras de deprecación

 

 

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\"");
    }
  }
}

Errores estables con RFC 7807

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

Validación consistente

  1. No aceptes basura y luego culpes al cliente.
record CreateUserReq(
  @NotBlank String email,
  @Size(max=50) String name
) {}


class UserController{

	@PostMapping
	ResponseEntity<UserDto> create(@Valid @RequestBody CreateUserReq req) {
	// Business logic here
	}

}

Tácticas de evolución seguras

Tolerancia a campos desconocidos

Que tu servidor y cliente no exploten si aparecen campos nuevos.

@JsonIgnoreProperties(ignoreUnknown = true)
record ClientRequest(
  String name,
  String lastName,
  Double balance,
  ...) {}

Paginación estable y cursors

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
}

Usando id monotónico como cursor

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

Patrón general de respuesta con cursor

Idempotencia

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

ETags y control de cambios

Respuestas con ETag + If-None-Match para caching y actualizaciones condicionales. No es glamuroso, pero evita lecturas inconsistentes sin tener que romper endpoints.

 

Contrato primero y documentación

OpenAPI como fuente de verdad

  1. Genera documentación con springdoc-openapi y publícala.
  2. • Congela el contrato en PRs y bloquea rupturas con openapi-diff en CI.
<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
  <version>2.6.0</version>
</dependency>

Contract tests (consumer‑driven)

Usa Spring Cloud Contract para verificar que sigues cumpliendo lo prometido a clientes.

 

  • Productor: publica stubs.
  • Consumidor: verifica que el stub sigue valiendo antes de que tú “optimices” algo y rompas medio ecosistema.

Golden tests de JSON

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

Infraestructura y herramientas útiles

Spring Gateway para versionado y rutas

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.

OpenAPI-diff

Analiza las diferencias entre dos especificaciones (v.3)

Migraciones de esquema controladas

 

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>

Ejemplo con Flyway

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

Reglas generales para no romper una API

  • 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.

Made with Slides.com