La aplicación de una academia de idiomas gestiona cursos, matrículas y pagos. Se despliega como un único programa y lleva unos años creciendo. En una reunión alguien lo resume así: «el monolito ya es muy grande, hay que dividirlo en microservicios».
Antes de abrir tres repositorios conviene hacer otra pregunta: dentro de ese programa, ¿hay fronteras o solo hay archivos acumulados? Si la parte de matrículas lee directamente las tablas de cursos, modifica sus contadores y conoce sus estados internos, separarlas en servicios no las desacopla. Las convierte en dos programas que se llaman por la red y siguen dependiendo de los mismos detalles.
Un monolito es una aplicación que se entrega como una sola unidad de despliegue. Un monolito modular organiza esa unidad en módulos con responsabilidades propias, una parte pública y unos detalles internos que nadie más toca. Vamos a construir dos de esos módulos y ver qué ganamos al mantenerlos dentro del mismo programa.
Grande no significa enredado
Un monolito no es un mal diseño por definición. El problema aparece cuando cualquier parte puede usar cualquier detalle de otra. Entonces un cambio pequeño en cursos obliga a revisar matrículas, pagos e informes, y nadie sabe con seguridad qué se rompe.
Esa situación tiene nombre, big ball of mud, una gran bola de barro, y no depende de cuántos programas despleguemos. Lo que la evita son las ideas de modularidad, cohesión y acoplamiento: agrupar lo que cambia junto y limitar lo que cada parte necesita saber de las demás.
Un módulo es útil cuando podemos explicar tres cosas: qué decisiones le pertenecen, qué ofrece a los demás y qué guarda para sí. En nuestra academia, el módulo de cursos decide cuántas plazas tiene cada curso y cuánto cuesta. El de matrículas decide qué significa matricular a un alumno.
La forma del proyecto
Esta es la organización que vamos a seguir. Cada módulo es un paquete de Java:
academy/
├── courses/
│ ├── CourseCatalog.java public: contract of the module
│ ├── CourseOffer.java public: data that leaves the module
│ ├── CoursesModule.java public: builds the module
│ ├── Course.java internal
│ └── CourseRecords.java internal
├── enrollments/
│ ├── EnrollStudent.java
│ ├── Enrollment.java
│ └── EnrollmentRepository.java
└── AcademyApplication.java composition
En Java, una clase sin el modificador public solo es visible dentro de su paquete. Lo aprovecharemos: lo que marca public es lo que el módulo ofrece; el resto es interno, y el compilador impide usarlo desde fuera. No hace falta una herramienta especial para empezar a proteger una frontera.
La parte pública del módulo de cursos
Matrículas necesita dos cosas de cursos: consultar la oferta de un curso y reservar una plaza. Ese es su contrato:
// courses/CourseCatalog.java
public interface CourseCatalog {
CourseOffer findOffer(String courseCode);
void reserveSeat(String courseCode);
}
findOffer() devuelve los datos que otro módulo puede conocer de un curso. reserveSeat() pide ocupar una plaza; si no quedan, falla con un error. Como vimos en dependencias y contratos, la firma se completa con estos significados.
Los datos que salen del módulo viajan en su propia clase:
// courses/CourseOffer.java
public class CourseOffer {
private final String code;
private final String title;
private final int priceInCents;
public CourseOffer(String code, String title, int priceInCents) {
this.code = code;
this.title = title;
this.priceInCents = priceInCents;
}
public String getCode() {
return code;
}
public String getTitle() {
return title;
}
public int getPriceInCents() {
return priceInCents;
}
}
CourseOffer no incluye la capacidad ni las plazas ocupadas. Matrículas no necesita esos números para hacer su trabajo, y si los tuviera acabaría tomando decisiones que pertenecen a cursos.
El interior del módulo
Dentro, un curso conoce su capacidad y protege su regla principal: no se pueden reservar más plazas de las que tiene.
// courses/Course.java
class Course {
private final String code;
private final String title;
private final int priceInCents;
private final int capacity;
private int reservedSeats;
Course(String code, String title, int priceInCents, int capacity) {
this.code = code;
this.title = title;
this.priceInCents = priceInCents;
this.capacity = capacity;
}
String getCode() {
return code;
}
void reserveSeat() {
if (reservedSeats >= capacity) {
throw new IllegalStateException("Course is full: " + code);
}
reservedSeats++;
}
CourseOffer toOffer() {
return new CourseOffer(code, title, priceInCents);
}
}
Ni la clase ni sus métodos son públicos. Desde el paquete enrollments, escribir new Course(...) daría un error de compilación.
CourseRecords guarda los cursos y cumple el contrato público:
// courses/CourseRecords.java
class CourseRecords implements CourseCatalog {
private final Map<String, Course> coursesByCode = new HashMap<>();
void add(Course course) {
coursesByCode.put(course.getCode(), course);
}
public CourseOffer findOffer(String courseCode) {
return find(courseCode).toOffer();
}
public void reserveSeat(String courseCode) {
find(courseCode).reserveSeat();
}
private Course find(String courseCode) {
Course course = coursesByCode.get(courseCode);
if (course == null) {
throw new IllegalArgumentException("Unknown course: " + courseCode);
}
return course;
}
}
Los métodos del contrato tienen que ser públicos porque implementan una interfaz, pero la clase no lo es: los demás módulos solo pueden usarla a través de CourseCatalog. Aquí los cursos se guardan en un mapa en memoria; en una aplicación real estarían en una base de datos, y esa decisión seguiría siendo interna.
Por último, una clase pública permite crear el módulo, dar de alta cursos y obtener su contrato:
// courses/CoursesModule.java
public class CoursesModule {
private final CourseRecords records = new CourseRecords();
public void addCourse(String code, String title, int priceInCents, int capacity) {
records.add(new Course(code, title, priceInCents, capacity));
}
public CourseCatalog catalog() {
return records;
}
}
El módulo de matrículas usa el contrato
Una matrícula registra qué alumno se apunta a qué curso y a qué precio. El precio se copia en el momento de matricularse: si el curso sube de precio el mes que viene, esa matrícula conserva el importe acordado.
// enrollments/Enrollment.java
public class Enrollment {
private final String studentId;
private final String courseCode;
private final int priceInCents;
public Enrollment(String studentId, String courseCode, int priceInCents) {
this.studentId = studentId;
this.courseCode = courseCode;
this.priceInCents = priceInCents;
}
public String getStudentId() {
return studentId;
}
public String getCourseCode() {
return courseCode;
}
public int getPriceInCents() {
return priceInCents;
}
}
// enrollments/EnrollmentRepository.java
public class EnrollmentRepository {
private final List<Enrollment> enrollments = new ArrayList<>();
public void save(Enrollment enrollment) {
enrollments.add(enrollment);
}
public List<Enrollment> findByCourse(String courseCode) {
List<Enrollment> result = new ArrayList<>();
for (Enrollment enrollment : enrollments) {
if (enrollment.getCourseCode().equals(courseCode)) {
result.add(enrollment);
}
}
return result;
}
}
El caso de uso coordina los dos módulos:
// enrollments/EnrollStudent.java
public class EnrollStudent {
private final CourseCatalog courses;
private final EnrollmentRepository enrollments;
public EnrollStudent(CourseCatalog courses, EnrollmentRepository enrollments) {
this.courses = courses;
this.enrollments = enrollments;
}
public Enrollment execute(String studentId, String courseCode) {
CourseOffer offer = courses.findOffer(courseCode);
courses.reserveSeat(courseCode);
Enrollment enrollment = new Enrollment(studentId, offer.getCode(), offer.getPriceInCents());
enrollments.save(enrollment);
return enrollment;
}
}
Primero consulta la oferta, después reserva la plaza y, si todo va bien, guarda la matrícula con el precio de esa oferta. EnrollStudent no sabe dónde están los cursos ni cómo se cuentan las plazas. Solo conoce CourseCatalog y CourseOffer.
Seguir tres matrículas
El punto de composición crea los módulos y los conecta:
CoursesModule coursesModule = new CoursesModule();
coursesModule.addCourse("ENG-B1", "English B1", 18000, 2);
EnrollmentRepository enrollments = new EnrollmentRepository();
EnrollStudent enrollStudent = new EnrollStudent(coursesModule.catalog(), enrollments);
enrollStudent.execute("STU-1", "ENG-B1"); // price 18000
enrollStudent.execute("STU-2", "ENG-B1"); // price 18000
enrollStudent.execute("STU-3", "ENG-B1"); // IllegalStateException: Course is full: ENG-B1
enrollStudent.execute("STU-4", "FR-A1"); // IllegalArgumentException: Unknown course: FR-A1
enrollments.findByCourse("ENG-B1").size(); // 2
Las dos primeras matrículas encuentran el curso, reservan plaza y se guardan con 18000 céntimos. En la tercera, findOffer() funciona, pero reserveSeat() encuentra las dos plazas ocupadas y lanza el error; save() no llega a ejecutarse. La cuarta falla antes, porque el curso no existe. Al final hay exactamente dos matrículas, una por cada plaza.

Las flechas muestran dependencias de código. El recuadro exterior es la unidad de despliegue: los dos módulos se compilan, se prueban y se publican juntos. La llamada de EnrollStudent a reserveSeat() es una llamada normal entre objetos del mismo programa: no hay red, no hay formato de mensaje y no puede quedarse a medio camino.
Cómo se rompe una frontera
Supongamos que un día alguien necesita, desde matrículas, saber si un curso tiene plazas libres. El camino rápido es consultar la tabla directamente:
// Counterexample: enrollments reads the internal table of courses
String sql = "SELECT capacity, reserved_seats FROM courses WHERE code = ?";
Funciona el primer día. Pero ahora matrículas conoce el nombre de una tabla, el de dos columnas y la forma de calcular las plazas libres. Si cursos empieza a reservar plazas para lista de espera, esa consulta seguirá devolviendo números, solo que ya no significarán lo mismo. La frontera de paquetes sigue en su sitio; la frontera real ha desaparecido.
La salida correcta es ampliar el contrato, por ejemplo con un método hasFreeSeat(courseCode), y que cursos decida qué significa «plaza libre».
No hace falta una base de datos por módulo para empezar. Pueden compartir servidor e incluso base de datos si cada módulo es el único que escribe en sus tablas y los demás acceden a esos datos a través de su contrato. Separar esquemas o usuarios con distintos permisos puede reforzar esa regla cuando el riesgo lo justifique.
Para las dependencias que el compilador no ve, como esa consulta, sirven las revisiones de código y las pruebas que comprueban qué paquetes usan a cuáles. Java también ofrece módulos con module-info.java, que permiten declarar qué paquetes exporta cada uno. Son refuerzos; la decisión de dónde está la frontera sigue siendo nuestra.
Lo que ganamos al seguir juntos
Mantener los módulos en el mismo programa tiene ventajas que a veces se dan por supuestas.
La primera es la transacción. En EnrollStudent hay dos escrituras: la reserva de plaza y la matrícula. Si los dos módulos guardan sus datos en la misma base, una única transacción local puede confirmar ambas o ninguna. Si la segunda falla, no se queda una plaza ocupada sin alumno.
Esa garantía es una dependencia entre módulos y conviene dejarla escrita. Si algún día cursos se separa, desaparecerá, y habrá que sustituirla por otros mecanismos. No es un error usarla, siempre que sepamos que la estamos usando.
La segunda es el coste de cambiar una frontera. Si descubrimos que la gestión de listas de espera pertenece a matrículas y no a cursos, moverla es una refactorización dentro del mismo programa: el compilador nos señala cada uso. Cuando la frontera cruza la red, el mismo cambio exige versionar un contrato, coordinar despliegues y migrar datos.
La tercera es la operación: un solo programa que desplegar, observar y configurar. Martin Fowler recoge esta ventaja en su reflexión sobre empezar con un monolito, que también reconoce sus límites.
Esos límites existen. Todos los módulos comparten memoria, recursos y despliegue. Un informe que agota la memoria puede tumbar también las matrículas, y para publicar un cambio en cursos hay que desplegar la aplicación entera. Tener un código modular y aislar los fallos en ejecución son propiedades distintas.
Módulos, capas y cortes verticales
Los módulos son una forma de dividir el sistema por capacidades del negocio. Dentro de cada uno podemos seguir organizando el código como mejor convenga: en capas, por cortes verticales o con la dirección de dependencias de Clean Architecture. No son alternativas: responden a preguntas de tamaño distinto.
Hay un riesgo frecuente: el módulo common o shared. Empieza con una utilidad para fechas y termina reuniendo reglas de cursos, matrículas y pagos. Entonces cada cambio en ese módulo afecta a todos los demás, y hemos movido el acoplamiento de sitio sin reducirlo. Lo compartido debería ser pequeño, estable y sin reglas de negocio.
También conviene vigilar los ciclos. Si matrículas usa cursos, cursos usa promociones y promociones vuelve a usar matrículas, es difícil entender cualquiera de ellos por separado. A veces se resuelve moviendo una regla al módulo al que pertenece; otras, revelando que dos módulos eran en realidad uno.
Antes de pensar en separar
Un monolito modular puede ser el diseño definitivo de muchas aplicaciones. También es un buen punto de partida si algún día hace falta separar un servicio, porque las fronteras ya se han probado.
Una forma práctica de comprobarlo: cambia cómo guarda cursos sus datos, de un mapa a una base de datos, sin tocar CourseCatalog. Si matrículas no se entera, la frontera funciona. Si el cambio obliga a modificar medio programa, ese trabajo hay que hacerlo dentro del proceso antes de convertir la frontera en una llamada de red.