Saltar al contenido principal

Expediente: migrar un servicio de reservas legado sin reescribirlo

6 min de lectura

Una migración no termina cuando el código nuevo funciona. Termina cuando el código viejo desaparece. Esa distinción parece obvia hasta que eres tú quien tiene delante un código legado que actúa como un servicio del que dependen, sin que nadie lo sepa muy bien, media docena de cosas más, y aunque "ya funciona", los riesgos cada vez son mayores. Vamos a condensar un poco lo redactado en artículos anteriores y aquí expondré la migración de uno de esos servicios, el de reservas, te enlazo la serie de testing, de principio a fin, usando la red de tests end-to-end que construimos en el artículo anterior ya que será el punto de partida que hace que cada uno de los siguientes pasos sea seguro de dar.

El servicio que nadie quiere tocar

Así es, más o menos, POST /reservations antes de que cambie nada:

app.post("/reservations", async (req, res) => {
  const { roomId, date } = req.body;
  if (!roomId || !date) return res.status(400).send("Missing fields");

  const existing = db
    .prepare("SELECT id FROM reservations WHERE room_id = ? AND date = ?")
    .get(roomId, date);
  if (existing) return res.status(409).send("Slot taken");

  const id = db
    .prepare("INSERT INTO reservations (room_id, date) VALUES (?, ?)")
    .run(roomId, date).lastInsertRowid;

  res.status(201).json({ id, roomId, date });
});

Nada de esto es raro en una base de código de quince años. Obtener la petición, la regla de conflicto y la llamada a la base de datos son las mismas doce líneas, y ese es exactamente el problema: no hay ninguna costura1 en esta función. Cambiar la regla que produce el conflicto (la posibilidad de que ocupen la misma reserva dos peticiones distintas) significa tocar el mismo bloque que habla con SQL. Cambiar de base de datos significa tocar el mismo bloque que decide quién gana una doble reserva. Cada cambio carga con el riesgo de todos los demás, porque nada está separado de nada. Antes de que esta serie construyera un test end-to-end2 alrededor de este mismo endpoint, ese riesgo era motivo suficiente para dejarlo tranquilo.

Empezamos a refactorizar por este endpoint, no porque sea el más fácil de tocar, sino porque es uno que tarde o temprano habrá que cambiar. Un bug en estas doce líneas concretas tiene dos posibles formas de fallar, y ambas son visibles para un cliente en cuestión de segundos: o bien dos personas acaban en la misma habitación el mismo día, o la comprobación falla al revés y rechaza una habitación que en realidad estaba libre. No hay forma silenciosa de equivocarse aquí.

Extraer la regla sin cambiar nada

El instinto, una vez que por fin tienes una red de seguridad con los tests E2E, es rediseñarlo todo de una vez: un módulo nuevo, una interfaz limpia, una capa de infraestructura con su repositorio... todo a la vez. Resístete. El primer paso cambia lo mínimo posible:

function assertSlotAvailable(db: Database, roomId: string, date: string) {
  const existing = db
    .prepare("SELECT id FROM reservations WHERE room_id = ? AND date = ?")
    .get(roomId, date);
  if (existing) throw new ConflictError("Slot taken");
}

El servicio llama a esta función en el mismo sitio donde antes vivía la comprobación en línea. Mismo SQL, mismo comportamiento, mismos códigos de respuesta. Lo único que se ha movido es dónde está escrita la regla, no lo que hace. Corre la suite end-to-end. Debería estar en verde, y debería seguir en verde, porque todavía no ha cambiado nada observable. Si no está en verde, para: esa es la señal de que la "extracción" cambió el comportamiento sin querer, y hay que arreglar ese bug antes de seguir con la migración, no después.

Este paso da la sensación de no conseguir nada, y esa es la gracia. Una migración que solo da pasos así de pequeños es una migración que puedes parar a mitad de camino sin dejar la base de código peor de lo que estaba. Una migración que da un solo paso gigante es una migración con la que estás comprometido a terminar bajo presión de fecha límite, funcione bien o no.

Si es un agente el que hace la extracción, aquí es también donde se gana su sitio la disciplina de plan-antes-que-código de la que hablamos antes en la serie: propón exactamente esta extracción, nombra la función a la que se mueve y nada más, y consigue que revisen ese plan antes de tocar código. Una extracción de una sola función se revisa en un minuto. Un plan que de paso mete la interfaz, el repositorio y el borrado del camino viejo dentro del "paso uno" no se revisa en un minuto, y es exactamente el tipo de plan más difícil de detectar en la revisión que de no haber propuesto nunca.

Pon una interfaz detrás de la regla, no de la base de datos

El segundo paso es donde cambia la arquitectura de verdad, y es un movimiento más acotado de lo que suena: la regla deja de llamar a db.prepare directamente y empieza a depender de una interfaz.

interface ReservationRepository {
  findConflict(roomId: string, date: string): Reservation | null;
  save(roomId: string, date: string): Reservation;
}

function assertSlotAvailable(repo: ReservationRepository, roomId: string, date: string) {
  if (repo.findConflict(roomId, date)) throw new ConflictError("Slot taken");
}

Un SqliteReservationRepository implementa esa interfaz con las mismas dos queries que el servicio ejecutaba antes en línea:

class SqliteReservationRepository implements ReservationRepository {
  constructor(private db: Database) {}

  findConflict(roomId: string, date: string): Reservation | null {
    return this.db
      .prepare("SELECT id FROM reservations WHERE room_id = ? AND date = ?")
      .get(roomId, date) ?? null;
  }

  save(roomId: string, date: string): Reservation {
    const id = this.db
      .prepare("INSERT INTO reservations (room_id, date) VALUES (?, ?)")
      .run(roomId, date).lastInsertRowid;
    return { id, roomId, date };
  }
}

El acceso a la base de datos no ha cambiado nada; lo que ha cambiado es que la regla de conflicto ya no sabe que está hablando con SQLite. Sabe que está hablando con algo que puede encontrar un conflicto y guardar una reserva, y ese es todo el contrato.

Este es el momento en que la regla se desacopla3 de verdad de la base de datos, y la dirección de las dependencias4 cambia. Antes, la regla de negocio dependía de la base de datos. Después, tanto la regla de negocio como la base de datos dependen de la misma interfaz, y ninguna de las dos depende directamente de la otra. Es un diagrama pequeño, pero es el diagrama para el que existía toda esta migración:

La migración no es que el código nuevo funcione. Es que el código viejo deje de ser lo único que puede funcionar.

La migración termina cuando desaparece el camino viejo

Llegados aquí es tentador darla por terminada. La interfaz existe, la regla está extraída, los tests están en verde. Pero el servicio original de doce líneas normalmente sigue ahí, en algún sitio de la base de código, comentado "por si acaso" o convertido en código muerto detrás de un feature flag que nadie ha desactivado. Eso no es una migración terminada; son dos sistemas ahora, uno de los cuales es inalcanzable pero le sigue costando a cualquiera que lea el archivo el esfuerzo de entenderlo.

El último paso de verdad es borrarlo. Quita el SQL en línea del servicio por completo, conéctalo exclusivamente a través de assertSlotAvailable y el repositorio, y corre la suite una vez más. Si está en verde, la migración ha terminado, no porque el camino nuevo funcione, sino porque el viejo ya no existe para que nadie pueda depender de él sin querer, extenderlo o copiarlo en el próximo servicio que necesite la misma lógica.

Esa última ejecución de la suite de tests hace más trabajo del que parece. Es la misma red end-to-end de antes en la serie, sin cambios desde la primera extracción, comprobando exactamente lo mismo que siempre comprobó: manda dos peticiones en conflicto, obtén exactamente un éxito. La implementación de debajo se ha reconstruido dos veces desde entonces. El contrato nunca se movió.

Conclusión

Ninguno de los tres pasos de aquí fue impresionante por sí solo. Saca una regla a su propia función. Pon una interfaz detrás. Borra lo que queda. Lo que ha hecho posible la migración ha sido el dar pasos lo bastante pequeños para verificar al momento y revertir si algo sale mal. Toda la secuencia terminó con algo eliminado, no solo con algo añadido. Una migración legada que solo añade código es una base de código que ahora carga con el riesgo viejo y la complejidad nueva a la vez. La que de verdad está terminada es aquella en la que, una semana después, nadie encuentra ya la función de doce líneas que hacía tan peligroso tocar este endpoint, porque ya no está.

Notas

  1. Costura: un punto del código donde puedes cambiar el comportamiento sin tocar el código de ahí mismo, normalmente pasando algo distinto como parámetro.

  2. Test end-to-end: un test que lanza una petición a través del sistema real, tal como lo haría un cliente, por HTTP, a través del handler real, hasta la base de datos real, y comprueba la respuesta.

  3. Desacoplamiento: reducir cuánto necesita saber una parte de un sistema sobre otra, para que cada una pueda cambiar sin arrastrar a la otra con ella.

  4. Dirección de las dependencias: hacia dónde apunta una flecha en un diagrama de dependencias, hacia la abstracción estable, no hacia el detalle concreto que la llame.

Artículos relacionados

Recibe nuevos artículos en tu correo

Escritura sobre arquitectura pragmática, sistemas mantenibles y trade-offs reales de ingeniería. Publicado cuando está listo.