Volvamos a la academia de idiomas del monolito modular. Sus módulos de cursos y matrículas tienen fronteras claras y llevan meses funcionando bien. Pero en septiembre, cuando se abren las matrículas, miles de personas consultan el catálogo de cursos a la vez y la aplicación entera se ralentiza, incluido el cobro de las cuotas. Además, el equipo que mantiene el catálogo quiere publicar cambios cada pocos días, y ahora tiene que esperar al despliegue conjunto de toda la aplicación.

Ahora hay dos problemas concretos: una parte necesita escalar por separado y otra necesita su propio ritmo de entrega. Con esos motivos encima de la mesa, merece la pena estudiar si conviene separar cursos en un servicio propio.

Los microservicios organizan una aplicación en servicios que se despliegan de forma independiente, se encargan de una capacidad del negocio, guardan sus propios datos y colaboran a través de contratos por la red. Vamos a separar el módulo de cursos y fijarnos en lo que cambia, que es bastante más que el lugar donde se ejecuta el código.

Qué hace que un servicio sea independiente

El tamaño no define un microservicio. Un servicio de doscientas líneas que obliga a desplegar a la vez otros cinco cada vez que cambia un campo tiene muy poca independencia, por pequeño que sea.

El artículo de James Lewis y Martin Fowler sobre microservicios destaca rasgos como organizarse alrededor de capacidades del negocio, desplegar de forma independiente y que cada servicio gestione sus datos. La frontera que proponemos, cursos, cumple el primero: es una capacidad que la academia reconoce, con reglas propias. Ya existía como módulo, con un contrato que matrículas usaba sin conocer su interior.

Partir por capas técnicas, con un servicio para los controladores, otro para la lógica y otro para el acceso a datos, suele dar el resultado contrario: cualquier funcionalidad nueva obliga a cambiar y desplegar los tres a la vez.

La misma pregunta, ahora por la red

En el monolito, matrículas reservaba una plaza con una llamada entre objetos: courses.reserveSeat("ENG-B1"). La respuesta llegaba siempre: o la plaza quedaba reservada o había un error. No existía una tercera posibilidad.

Cuando cursos pasa a ser un servicio, esa llamada viaja por la red. La petición tiene que convertirse en un mensaje, por ejemplo una petición HTTP. Hay que decidir cuánto tiempo esperamos la respuesta. Y aparece una situación nueva: la petición llega, el servicio reserva la plaza y la respuesta se pierde por el camino.

Desde matrículas, ese caso es indistinguible de uno en el que la petición nunca llegó. Solo sabemos que no tenemos respuesta. Por eso el resultado de reservar ya no puede ser «sí o error»: necesita un tercer estado.

enum ReservationStatus {
    RESERVED,
    COURSE_FULL,
    UNKNOWN
}

UNKNOWN no significa que la reserva fallase. Significa que no sabemos qué pasó. Tratarlo como un fallo nos llevaría a decirle al alumno que no hay plaza cuando quizá ya la tiene ocupada.

El cliente del servicio de cursos

Para hablar con el servicio necesitamos un contrato de transporte. En un sistema real lo implementaría un cliente HTTP; aquí nos centramos en lo que promete:

interface CoursesClient {
    String reserveSeat(String requestId, String courseCode) throws TimeoutException;
}

El servicio responde con un texto, "RESERVED" o "COURSE_FULL". Si la respuesta no llega a tiempo, el método lanza TimeoutException. El parámetro requestId es nuevo: identifica cada intento de matrícula para que el servicio reconozca una petición repetida, como veremos enseguida.

Matrículas no debería trabajar con textos sueltos ni con excepciones de red. Una clase intermedia traduce la conversación remota a los estados que entiende la aplicación:

class RemoteSeatReservations {
    private final CoursesClient client;

    public RemoteSeatReservations(CoursesClient client) {
        this.client = client;
    }

    public ReservationStatus reserve(String requestId, String courseCode) {
        try {
            String reply = client.reserveSeat(requestId, courseCode);
            if (reply.equals("RESERVED")) {
                return ReservationStatus.RESERVED;
            }
            if (reply.equals("COURSE_FULL")) {
                return ReservationStatus.COURSE_FULL;
            }
            throw new IllegalStateException("Unexpected reply: " + reply);
        } catch (TimeoutException exception) {
            return ReservationStatus.UNKNOWN;
        }
    }
}

Si el texto es uno de los dos conocidos, lo convierte en su estado. Si llega algo distinto, el contrato se ha roto y lo señala con un error claro en lugar de adivinar. Si vence el tiempo de espera, devuelve UNKNOWN. Es un adaptador en el sentido de la arquitectura hexagonal: la aplicación conoce estados con significado y el detalle del transporte queda en el borde.

Simular el servicio para seguir el recorrido

Para observar el comportamiento sin montar una red, usamos un sustituto que imita al servicio de cursos. Gestiona un único curso, con una capacidad dada, y puede perder a propósito la siguiente respuesta:

class SimulatedCoursesClient implements CoursesClient {
    private final int capacity;
    private final Set<String> acceptedRequests = new HashSet<>();
    private boolean loseNextReply;

    public SimulatedCoursesClient(int capacity) {
        this.capacity = capacity;
    }

    public void loseNextReply() {
        loseNextReply = true;
    }

    public String reserveSeat(String requestId, String courseCode) throws TimeoutException {
        if (!acceptedRequests.contains(requestId)) {
            if (acceptedRequests.size() >= capacity) {
                return "COURSE_FULL";
            }
            acceptedRequests.add(requestId);
        }
        if (loseNextReply) {
            loseNextReply = false;
            throw new TimeoutException("Reply lost after reserving the seat.");
        }
        return "RESERVED";
    }

    public int reservedSeats() {
        return acceptedRequests.size();
    }
}

Cada requestId aceptado ocupa una plaza. Si llega un identificador que ya se aceptó, no reserva otra: responde que esa petición tiene su plaza. Cuando loseNextReply está activado, la reserva se guarda igualmente y después se lanza la excepción, igual que si la respuesta se perdiese en la red.

Seguimos ahora cuatro peticiones para un curso con dos plazas:

SimulatedCoursesClient coursesService = new SimulatedCoursesClient(2);
RemoteSeatReservations reservations = new RemoteSeatReservations(coursesService);

reservations.reserve("ENR-1", "ENG-B1"); // RESERVED

coursesService.loseNextReply();
reservations.reserve("ENR-2", "ENG-B1"); // UNKNOWN
coursesService.reservedSeats();          // 2: the seat was reserved anyway

reservations.reserve("ENR-2", "ENG-B1"); // RESERVED: same request, no new seat
coursesService.reservedSeats();          // 2

reservations.reserve("ENR-3", "ENG-B1"); // COURSE_FULL

La primera reserva funciona sin sorpresas. En la segunda, el servicio ocupa la plaza, pero la respuesta se pierde y matrículas recibe UNKNOWN. El contador del servicio confirma que la plaza sí está ocupada.

Matrículas vuelve a preguntar con el mismo identificador, ENR-2. El servicio lo reconoce y responde RESERVED sin ocupar una tercera plaza. Si el reintento hubiera usado un identificador nuevo, el servicio habría contado dos alumnos donde solo hay uno. Con la siguiente petición distinta, ENR-3, el curso ya está lleno.

Esta capacidad de repetir una petición sin duplicar su efecto se llama idempotencia, y en un sistema distribuido deja de ser un detalle. Hay que diseñarla en el contrato: el servicio tiene que guardar qué identificadores ha aceptado, y de forma duradera, no en una lista que se pierde al reiniciar.

Matrículas y cursos se despliegan por separado, cada uno con su base de datos; la reserva viaja por la red y su respuesta puede retrasarse o perderse.

Los dos recuadros son despliegues independientes, cada uno con su base de datos. La flecha entre ellos ya no es una dependencia de código, sino una petición por la red. Ese tramo es nuevo respecto al monolito y es donde aparecen el tiempo de espera, la respuesta perdida y la necesidad del identificador de petición.

Lo que perdemos al separar los datos

En el monolito modular reservar plaza y guardar la matrícula podían ir en una misma transacción: se confirmaban las dos escrituras o ninguna. Ahora cada servicio tiene su propia base de datos y esa transacción común ya no existe.

Si cursos reserva la plaza y matrículas no consigue guardar la matrícula, queda una plaza ocupada sin alumno. Hay que decidir qué hacer en ese caso: liberar la plaza con otra petición, guardar primero la matrícula como pendiente o revisar periódicamente las reservas que no tienen matrícula. Todas esas soluciones son posibles, y todas son trabajo que en el monolito no existía.

Tampoco podemos unir tablas de los dos servicios en una consulta. Si un informe necesita los cursos con sus matrículas, tendrá que pedir datos a ambos servicios o mantener una copia preparada para leer, y decidir cuánto retraso admite esa copia.

Que cada servicio guarde sus datos es justo lo que permite su independencia. Si matrículas siguiera leyendo las tablas de cursos, cualquier cambio en ellas obligaría a desplegar los dos servicios a la vez. Tendríamos dos programas y la misma dependencia.

Operar también es diseñar

Cada servicio nuevo necesita su configuración, sus credenciales, su proceso de despliegue y alguien responsable de él. Cuando algo falla, ya no basta con mirar los registros de un programa: hay que seguir una petición a través de varios servicios. Por eso las señales de observabilidad pasan a ser una necesidad básica.

El contrato entre servicios también tiene que evolucionar con cuidado. Matrículas y cursos ya no se despliegan juntos, así que durante un tiempo convivirán versiones distintas. Añadir un campo a una respuesta suele ser seguro; cambiar el significado de "RESERVED" obliga a coordinar a todos los consumidores. Si cada despliegue necesita una reunión para publicarlo todo en orden, quizá hemos construido un monolito distribuido: todos los costes de la red y ninguna de las ventajas de la independencia.

Las plataformas ayudan con parte de este trabajo. Los contenedores empaquetan cada servicio y un orquestador los distribuye y vigila. Ninguna herramienta decide qué datos pertenecen a cada servicio ni qué hacer con una respuesta desconocida. Esas decisiones siguen siendo de diseño.

Comparar con alternativas más pequeñas

Antes de separar, conviene comprobar si una opción menor resuelve el problema. Si lo que falla es la carga de septiembre, quizá baste con ejecutar varias copias del monolito detrás de un balanceador o con guardar en memoria las consultas del catálogo, que cambia poco. Si lo que molesta es un proceso pesado, como generar informes, puede ejecutarse en un trabajador aparte que se despliega con la misma aplicación.

Separar cursos se justifica cuando las dos necesidades se mantienen: escalar el catálogo sin multiplicar toda la aplicación y permitir que su equipo publique a su ritmo. Además, la frontera tiene que estar ya probada como módulo. Separar un módulo enredado solo traslada el enredo a la red, donde resulta más caro de arreglar.

Una decisión defendible se puede escribir como un objetivo comprobable: «separamos cursos para que la carga del catálogo no ralentice los cobros y para que su equipo despliegue sin esperar al resto; lo comprobaremos parando el servicio de cursos y verificando que los cobros siguen funcionando». Si el objetivo solo dice «queremos microservicios», todavía no explica qué problema de la academia resuelve la separación.