Una aplicación para repartir los gastos de un viaje permite exportarlos. Primero, como CSV para abrirlos en una hoja de cálculo. Después alguien pide un resumen corto para mandarlo por mensaje. Luego, una tabla para pegarla en un documento. Cada petición termina en el mismo método, que crece con un if más y una forma nueva de colocar los mismos datos.

El problema no es que haya tres formatos. Es que, para añadir el cuarto, hay que abrir una función que conoce los tres anteriores, y cualquier descuido puede romper una exportación que nadie había pedido tocar.

Una arquitectura con plugins, también llamada de microkernel, separa un núcleo estable de unas extensiones que cumplen un contrato conocido. El núcleo ofrece una capacidad, exportar, y cada plugin aporta una variante, un formato. Vamos a pasar de ese método creciente a un núcleo que acepta formatos nuevos sin cambiar.

El punto de partida

Este es el código inicial. Lo mostramos como contraejemplo, para ver qué queremos evitar:

class LegacyExpenseExport {
    public String export(String format, List<Expense> expenses) {
        if (format.equals("csv")) {
            StringBuilder text = new StringBuilder("description;amount");
            for (Expense expense : expenses) {
                text.append("\n").append(expense.getDescription())
                        .append(";").append(MoneyFormat.euros(expense.getAmountInCents()));
            }
            return text.toString();
        } else if (format.equals("summary")) {
            int total = 0;
            for (Expense expense : expenses) {
                total += expense.getAmountInCents();
            }
            return expenses.size() + " expenses, total " + MoneyFormat.euros(total) + " EUR";
        }
        throw new IllegalArgumentException("Unknown format: " + format);
    }
}

Cada formato está bien por separado. El coste aparece al crecer: un tercer formato añade otra rama a este método, sus pruebas tienen que convivir con las de los demás y la clase tiene tantos motivos para cambiar como formatos existan. Es lo que el principio abierto/cerrado de SOLID intenta evitar: deberíamos poder añadir comportamiento nuevo sin editar el que ya funciona.

Los datos que recibe un plugin

Antes de escribir el contrato, definimos qué datos comparte el núcleo con las extensiones. Un gasto tiene una descripción y un importe en céntimos:

class Expense {
    private final String description;
    private final int amountInCents;

    public Expense(String description, int amountInCents) {
        if (description == null || description.trim().isEmpty()) {
            throw new IllegalArgumentException("Description is required.");
        }
        if (amountInCents <= 0) {
            throw new IllegalArgumentException("Amount must be positive.");
        }
        this.description = description;
        this.amountInCents = amountInCents;
    }

    public String getDescription() {
        return description;
    }

    public int getAmountInCents() {
        return amountInCents;
    }
}

El constructor rechaza gastos sin descripción o con importes que no sean positivos. Gracias a eso, ningún plugin tiene que repetir esas comprobaciones: todo gasto que recibe ya es válido.

El núcleo también ofrece una utilidad para escribir importes, que los plugins pueden usar:

class MoneyFormat {
    public static String euros(int amountInCents) {
        int euros = amountInCents / 100;
        int cents = amountInCents % 100;
        return euros + "." + String.format("%02d", cents);
    }
}

Con 4550 céntimos, la división entera da 45 euros y el resto, 50 céntimos: "45.50". %02d completa con un cero cuando hace falta, así que 705 céntimos se escriben "7.05".

Esta decisión importa más de lo que parece. Lo que el núcleo pone a disposición de los plugins, Expense y MoneyFormat, se convierte en parte de lo que promete mantener. Si mañana cambia cómo se representa un importe, todos los plugins se ven afectados. Por eso conviene ofrecer poco y bien definido.

Un contrato pequeño

El contrato de un exportador responde a dos preguntas: cómo se llama el formato y qué texto produce a partir de unos gastos.

interface ExpenseExporter {
    String getFormatName();

    String export(List<Expense> expenses);
}

Igual que explicamos en dependencias y contratos, la firma necesita acompañarse de su significado. El nombre del formato no puede estar vacío y es único entre los plugins registrados. export() recibe una lista que puede estar vacía y no debe modificarla.

Es la misma idea que permitía combinar los filtros de una tubería: todas las piezas cumplen un contrato común y, por eso, quien las coordina puede tratarlas igual sin conocer los detalles de cada una.

Las dos primeras extensiones recuperan los formatos del código inicial, ahora cada uno en su clase:

class CsvExporter implements ExpenseExporter {
    public String getFormatName() {
        return "csv";
    }

    public String export(List<Expense> expenses) {
        StringBuilder text = new StringBuilder("description;amount");
        for (Expense expense : expenses) {
            text.append("\n")
                    .append(expense.getDescription())
                    .append(";")
                    .append(MoneyFormat.euros(expense.getAmountInCents()));
        }
        return text.toString();
    }
}

class SummaryExporter implements ExpenseExporter {
    public String getFormatName() {
        return "summary";
    }

    public String export(List<Expense> expenses) {
        int total = 0;
        for (Expense expense : expenses) {
            total += expense.getAmountInCents();
        }
        return expenses.size() + " expenses, total " + MoneyFormat.euros(total) + " EUR";
    }
}

CsvExporter escribe una cabecera y una línea por gasto, separando los campos con punto y coma. Es una simplificación: un CSV completo tendría que tratar las descripciones que contienen ese separador o saltos de línea. SummaryExporter suma los importes y los presenta en una frase.

El núcleo registra y delega

El núcleo guarda los plugins por nombre de formato y, cuando le piden una exportación, busca el adecuado y le cede el trabajo:

class ExportService {
    private final Map<String, ExpenseExporter> exporters = new HashMap<>();

    public void register(ExpenseExporter exporter) {
        String format = exporter.getFormatName();
        if (exporters.containsKey(format)) {
            throw new IllegalArgumentException("Format already registered: " + format);
        }
        exporters.put(format, exporter);
    }

    public String export(String format, List<Expense> expenses) {
        ExpenseExporter exporter = exporters.get(format);
        if (exporter == null) {
            throw new IllegalArgumentException("Unknown format: " + format);
        }
        return exporter.export(new ArrayList<>(expenses));
    }

    public List<String> availableFormats() {
        List<String> formats = new ArrayList<>(exporters.keySet());
        Collections.sort(formats);
        return formats;
    }
}

register() rechaza un segundo plugin con el mismo nombre. Sin esa comprobación, el último en registrarse sustituiría en silencio al anterior y nadie sabría qué formato está activo. export() entrega al plugin una copia de la lista: si una extensión la modificase por error, la lista de la aplicación seguiría intacta. availableFormats() permite mostrar al usuario qué opciones existen.

Fíjate en lo que no aparece: ni punto y coma, ni sumas, ni el texto "csv". El núcleo sabe seleccionar y coordinar; los formatos viven fuera.

Seguir una exportación

En el arranque de la aplicación registramos los plugins disponibles y después exportamos tres gastos:

ExportService exports = new ExportService();
exports.register(new CsvExporter());
exports.register(new SummaryExporter());

List<Expense> trip = Arrays.asList(
        new Expense("Hotel", 12000),
        new Expense("Train", 4550),
        new Expense("Dinner", 2730));

exports.export("summary", trip); // "3 expenses, total 192.80 EUR"
exports.export("csv", trip);
// description;amount
// Hotel;120.00
// Train;45.50
// Dinner;27.30
exports.export("pdf", trip); // IllegalArgumentException: Unknown format: pdf

Con "summary", el mapa devuelve el SummaryExporter. El plugin suma 12000 + 4550 + 2730 = 19280 céntimos y los escribe como 192.80. Con "csv", el núcleo hace exactamente lo mismo, buscar y delegar, pero el plugin elegido produce otro texto. Con "pdf" no hay ningún plugin registrado y el núcleo lo dice claramente.

Añadir un formato sin tocar el núcleo

Ahora llega el tercer formato: una tabla en Markdown para pegarla en un documento.

class MarkdownTableExporter implements ExpenseExporter {
    public String getFormatName() {
        return "markdown";
    }

    public String export(List<Expense> expenses) {
        StringBuilder text = new StringBuilder("| Description | Amount |\n| --- | ---: |");
        for (Expense expense : expenses) {
            text.append("\n| ")
                    .append(expense.getDescription())
                    .append(" | ")
                    .append(MoneyFormat.euros(expense.getAmountInCents()))
                    .append(" |");
        }
        return text.toString();
    }
}

Para incorporarlo basta con una línea en el arranque: exports.register(new MarkdownTableExporter());. ExportService, CsvExporter y SummaryExporter no cambian, y sus pruebas tampoco. availableFormats() devuelve ahora [csv, markdown, summary].

ExportService utiliza ExpenseExporter; CsvExporter, SummaryExporter y MarkdownTableExporter implementan ese contrato.

Las flechas continuas significan «utiliza este tipo» y las discontinuas, «implementa este contrato». El núcleo solo conoce ExpenseExporter. Las clases concretas aparecen únicamente en el arranque, que es quien decide qué plugins se registran. En ejecución, ExportService llama al objeto concreto que encontró en su mapa.

Plugins y adaptadores se parecen, pero no son lo mismo

Si vienes de la arquitectura hexagonal, el diagrama te resultará familiar: un contrato del núcleo y varias implementaciones. La diferencia está en para qué existen.

Un adaptador conecta el núcleo con algo que necesita, como un canal para enviar avisos, y normalmente se elige uno al montar la aplicación. Los plugins conviven a la vez y cada uno añade una opción que el usuario puede escoger. Las dos ideas encajan bien juntas: un plugin podría usar un adaptador para escribir su resultado en un archivo.

La forma de registrar también es una decisión aparte. Aquí lo hacemos a mano al arrancar, que es lo más fácil de seguir. Java ofrece ServiceLoader para descubrir implementaciones declaradas en las bibliotecas disponibles, y hay aplicaciones que cargan plugins desde una carpeta mientras se ejecutan. Cada opción resuelve cómo encontrar las extensiones, pero ninguna decide qué significa el contrato ni qué hacer con dos plugins que dicen ser "csv".

El contrato es lo que hay que cuidar

Los plugins funcionan mientras todos entienden el contrato de la misma manera. Una firma de Java no garantiza eso. Si un día decidimos que los importes se escriben con coma decimal, export() sigue teniendo la misma firma, pero los plugins antiguos producirán un resultado distinto del esperado.

Una práctica útil es preparar unas pruebas de contrato que todo plugin debe superar: con una lista vacía no falla, el nombre del formato no está vacío y la lista recibida no se modifica. Cada plugin nuevo se comprueba con las mismas reglas, una aplicación directa de diseñar para comprobar el comportamiento.

Cuando el contrato tenga que cambiar de forma incompatible, conviene hacerlo visible, por ejemplo con una versión que el núcleo pueda consultar al registrar. Y antes de ampliarlo con campos como el tipo de archivo o un nombre sugerido, hay que preguntarse si algún formato real los necesita. Diseñar el contrato para formatos que todavía no existen es una forma elegante de adelantarse al trabajo equivocado.

Una interfaz no protege de un plugin que falla

En este diseño, los plugins se ejecutan dentro de la misma aplicación. Uno defectuoso puede entrar en un bucle infinito, consumir toda la memoria o acceder a cualquier recurso que tenga la aplicación. La interfaz organiza el código; no lo aísla.

Con extensiones propias, suele bastar con tratar sus errores para que un formato defectuoso no impida usar los demás. Con extensiones de terceros, la confianza cambia: puede hacer falta ejecutarlas en otro proceso, con permisos limitados y un tiempo máximo. Esa protección tiene un coste de operación que conviene asumir solo cuando el riesgo lo justifica.

Cuándo compensa

Este estilo encaja cuando hay una familia de variantes que crece con cierta independencia: formatos de exportación, métodos de importación, reglas de validación configurables o integraciones con distintos proveedores. Y, sobre todo, cuando podemos describir un contrato estable para todas ellas.

Para dos formatos que no van a cambiar, una clase con dos métodos claros puede ser suficiente. Es la advertencia de YAGNI: el registro, el contrato y sus pruebas son piezas que habrá que mantener.

La comprobación práctica es sencilla: añade un formato y mira qué has tenido que tocar. Si basta con una clase nueva y una línea de registro, el límite funciona. Si has necesitado un if especial dentro del núcleo, el contrato todavía no recoge bien lo que varía.