Programación en Java
Patrones de diseño creacionales en Java: Singleton, Factory, Builder, Prototype
Intermedio8 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Los patrones creacionales separan cómo se crean los objetos de cómo se usan, y eso es lo que te permite cambiar de implementación, garantizar invariantes y mantener la lógica de construcción en un solo sitio. Esta guía explica Singleton, Factory Method, Abstract Factory, Builder y Prototype con código Java 21 idiomático, muestra dónde aparece cada uno en el JDK y en Spring, y dice con franqueza cuáles se usan en exceso y qué los sustituye en el lenguaje moderno: enums, records, static factories e inyección de dependencias.
Lo que aprenderás
- Explicar el problema que resuelve cada patrón creacional y reconocerlo en el JDK y en los frameworks
- Implementar correctamente un Singleton thread-safe y saber cuándo debería gestionarlo un contenedor DI
- Usar static factories y Factory Method para desacoplar a quien llama de las clases concretas
- Construir objetos inmutables con muchos parámetros opcionales mediante el patrón Builder
- Valorar cuándo un patrón aporta claridad y cuándo solo añade ceremonia
Los patrones de diseño son soluciones con nombre para problemas de diseño recurrentes. Los nombres importan tanto como las soluciones: decir «conviértelo en un Builder» o «eso en realidad es una Abstract Factory» comunica toda una estructura en tres palabras. Los cinco patrones creacionales del catálogo original atacan un mismo problema —el código que hace new ConcreteThing(...) queda soldado a esa clase concreta y a los detalles de su construcción— y lo resuelven en situaciones distintas. Esta guía cubre cada uno con código Java 21 y, igual de importante, señala dónde el lenguaje y los frameworks han absorbido el patrón hasta el punto de que ya no necesitas escribirlo a mano.
Por qué la construcción necesita patrones
Una llamada a new fija tres decisiones de golpe: qué clase, qué argumentos y cuándo. A veces esas decisiones corresponden a otro sitio: a la configuración, a una subclase, a un objeto que conoce las reglas para construir una instancia válida. Hay tres síntomas que apuntan a un patrón creacional:
- Una clase se instancia en muchos lugares y necesitas cambiar qué implementación se usa, o añadir caché, pooling o logging alrededor de la creación.
- Un constructor ha acumulado muchos parámetros, la mayoría opcionales, y quienes lo llaman pasan
nullo0en los que no les interesan. - Crear un objeto requiere varios pasos, validación entre campos o el conocimiento de otros objetos que quien llama no tendría por qué tener.
Los patrones siguientes son las respuestas clásicas. Antes de verlos, merece la pena mencionar una construcción que sirve de base a varios: el static factory method (LocalDate.of, List.of, Executors.newFixedThreadPool). A diferencia de un constructor, tiene un nombre descriptivo, puede devolver una instancia cacheada o ya existente, puede devolver un subtipo o una interfaz y puede elegir la clase en tiempo de ejecución. Muchos de estos patrones son static factories con algo más de estructura.
Singleton
Intención: garantizar que una clase tenga exactamente una instancia y ofrecer un punto de acceso global a ella.
La implementación de manual —constructor privado, instancia estática, getter estático— se ha escrito mal tantas veces que merece un tratamiento preciso. En la JVM, las formas correctas más sencillas son un enum o el holder de inicialización bajo demanda:
// Enum singleton: serialisation-safe, reflection-safe, thread-safe by the JLS
public enum ClockSource {
INSTANCE;
private final Clock clock = Clock.systemUTC();
public Instant now() { return clock.instant(); }
}
// Holder idiom: lazy, thread-safe without synchronisation, for classes that cannot be enums
public final class Registry {
private Registry() {}
private static final class Holder {
static final Registry INSTANCE = new Registry(); // initialised on first access to Holder
}
public static Registry get() { return Holder.INSTANCE; }
}
Ambas se apoyan en la garantía de inicialización de clases de la JVM: una clase se inicializa exactamente una vez, bajo un lock que gestiona la JVM, y el resultado es visible para todos los threads. La famosa versión con double-checked locking —if (instance == null) synchronized (...) { if (instance == null) instance = new ...}— solo es correcta si el campo es volatile, e incluso así exige más código que el holder, que no necesita ninguno.
Lo que hace polémico al patrón son sus costes. Un Singleton obtenido con Registry.get() dentro de una clase es una dependencia oculta: nada en el constructor de la clase dice que necesita un Registry, los tests no pueden sustituirlo y todos comparten su estado. La solución moderna es conservar la propiedad —una sola instancia— y renunciar al acceso global: deja que un contenedor de inyección de dependencias cree esa única instancia y se la entregue a quien declare que la necesita. Un bean de Spring tiene scope singleton por defecto, y por eso las aplicaciones Spring rara vez contienen un método getInstance(). Reserva el patrón escrito a mano para código sin contenedor y para recursos realmente globales al proceso, como un backend de logging.
Factory Method
Intención: definir una interfaz para crear un objeto, pero dejar que las subclases —o una función que se le proporciona— decidan qué clase instanciar.
La forma clásica es un método abstracto en una superclase al que llama la lógica de plantilla de esta:
public abstract class ReportExporter {
public final Path export(Report report, Path dir) throws IOException {
Writer writer = createWriter(); // the factory method
Path target = dir.resolve(report.name() + "." + writer.extension());
Files.writeString(target, writer.render(report));
return target;
}
protected abstract Writer createWriter();
public interface Writer {
String extension();
String render(Report report);
}
}
public final class CsvExporter extends ReportExporter {
@Override protected Writer createWriter() { return new CsvWriter(); }
}
export es fijo; con qué exporta lo decide la subclase. java.util.Calendar.getInstance(), NumberFormat.getInstance() y el método Collection.iterator() —cada colección crea su propio tipo de iterador— son ejemplos del JDK.
Desde Java 8, la subclase suele sobrar: pasa la creación como un Supplier<Writer> o una Function<Config, Writer> en el constructor. Es el mismo patrón con una lambda en lugar de una sobrescritura, y se compone mejor: puedes construir un Map<String, Supplier<Writer>> indexado por nombre de formato y buscar la factory en tiempo de ejecución, que es como se suelen construir los puntos de extensión tipo plugin. ThreadFactory, Collectors.toCollection(ArrayList::new) y Stream.generate(supplier) en el JDK reciben factories de esta manera.
Una forma sencilla emparentada es la factory parametrizada: un único método estático con un switch que devuelve una implementación distinta según el argumento. Es el primer paso adecuado cuando hay pocas implementaciones y quien llama sabe cuál quiere por su nombre; con una interfaz sealed, el compilador comprueba que el switch sea exhaustivo.
Abstract Factory
Intención: proporcionar una interfaz para crear familias de objetos relacionados sin nombrar sus clases concretas.
Mientras Factory Method crea un producto, Abstract Factory crea varios que deben ser coherentes entre sí. El ejemplo canónico es un toolkit de interfaz gráfica —botones, menús y diálogos tienen que ser todos del mismo look-and-feel—, y en el backend la misma forma aparece como un conjunto de componentes de infraestructura relacionados:
public interface StorageFactory {
BlobStore blobs();
QueueClient queue();
LockProvider locks();
}
public final class AwsStorageFactory implements StorageFactory {
private final Region region;
public AwsStorageFactory(Region region) { this.region = region; }
@Override public BlobStore blobs() { return new S3BlobStore(region); }
@Override public QueueClient queue() { return new SqsQueueClient(region); }
@Override public LockProvider locks() { return new DynamoLockProvider(region); }
}
public final class LocalStorageFactory implements StorageFactory {
@Override public BlobStore blobs() { return new DirectoryBlobStore(Path.of("data")); }
@Override public QueueClient queue() { return new InMemoryQueue(); }
@Override public LockProvider locks() { return new FileLockProvider(); }
}
El código de la aplicación recibe una StorageFactory y nunca menciona S3 ni el directorio local. Cambiar toda la familia es cambiar un solo objeto. El diseño DriverManager/Connection de JDBC es, en espíritu, una abstract factory: una Connection crea Statement y PreparedStatement acordes con su base de datos.
En una aplicación Spring, los perfiles y las clases @Configuration cumplen este papel —una configuración @Profile("local") define la familia local de beans y otra define la familia cloud—, así que las interfaces de factory explícitas aparecen sobre todo en librerías y en código sin framework.
Builder
Intención: separar la construcción de un objeto complejo de su representación para que el mismo proceso pueda producir representaciones distintas; en la práctica, ofrecer una forma legible y paso a paso de crear un objeto inmutable con muchas partes opcionales.
En Java adopta la forma de una clase estática anidada con setters fluidos y un build() que valida y crea el producto inmutable:
public final class HttpRequestSpec {
private final URI uri;
private final String method;
private final Map<String, String> headers;
private final Duration timeout;
private final byte[] body;
private HttpRequestSpec(Builder b) {
this.uri = b.uri;
this.method = b.method;
this.headers = Map.copyOf(b.headers); // defensive, immutable
this.timeout = b.timeout;
this.body = b.body == null ? new byte[0] : b.body.clone();
}
public static Builder builder(URI uri) { return new Builder(uri); }
public static final class Builder {
private final URI uri; // required: constructor parameter
private String method = "GET"; // optional: sensible defaults
private final Map<String, String> headers = new LinkedHashMap<>();
private Duration timeout = Duration.ofSeconds(30);
private byte[] body;
private Builder(URI uri) { this.uri = Objects.requireNonNull(uri); }
public Builder method(String method) { this.method = method; return this; }
public Builder header(String name, String v) { headers.put(name, v); return this; }
public Builder timeout(Duration timeout) { this.timeout = timeout; return this; }
public Builder body(byte[] body) { this.body = body; return this; }
public HttpRequestSpec build() {
if (body != null && method.equals("GET")) {
throw new IllegalStateException("GET requests cannot carry a body"); // cross-field rule
}
return new HttpRequestSpec(this);
}
}
}
HttpRequestSpec spec = HttpRequestSpec.builder(URI.create("https://api.example.com/orders"))
.method("POST")
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(5))
.body(payload)
.build();
La llamada nombra cada valor, el producto es inmutable, los valores por defecto están en un solo sitio y la validación entre campos se ejecuta una vez en build(). El JDK usa esta forma en HttpRequest.newBuilder(), ProcessBuilder, StringBuilder (un builder de cadenas) y Stream.Builder; el @Builder de Lombok la genera, y los records con muchos componentes suelen ir acompañados de un builder escrito a mano.
Conviene conocer dos variantes. Un staged builder usa una cadena de interfaces para obligar a llamar a los pasos requeridos en orden (.uri(...) devuelve un tipo que solo ofrece .method(...)): más seguro, pero más código. Y un builder sobre un record puede reducirse a una clase mutable cuyo build() llama al constructor canónico del record, de modo que la validación sigue en el constructor compacto del record.
No escribas un Builder para un tipo con dos campos obligatorios; un constructor o un record es más claro. El patrón compensa a partir de cuatro parámetros o cuando la opcionalidad es real.
Prototype
Intención: crear objetos nuevos copiando una instancia existente en lugar de construirlos desde cero.
Prototype importa cuando construir un objeto es caro (cargó una configuración grande, compiló una plantilla, abrió un pool de conexiones) o cuando quien llama quiere «uno como ese, con un pequeño cambio». El mecanismo integrado de Java es Object.clone() con la interfaz marcadora Cloneable, y se considera en general un error de diseño: clone() es protected en Object, hace una copia superficial campo a campo, se salta los constructores y no permite asignar a los campos final valores distintos de los del original. Mejor copiar de forma explícita:
public final class PipelineConfig {
private final List<String> stages;
private final Map<String, String> options;
public PipelineConfig(List<String> stages, Map<String, String> options) {
this.stages = List.copyOf(stages);
this.options = Map.copyOf(options);
}
public PipelineConfig(PipelineConfig other) { // copy constructor
this(other.stages, other.options);
}
public PipelineConfig withOption(String key, String value) { // "wither": copy with one change
Map<String, String> next = new HashMap<>(options);
next.put(key, value);
return new PipelineConfig(stages, next);
}
}
Con tipos inmutables el patrón se vuelve casi invisible: un método «wither» devuelve una copia modificada sin tocar el original, lo que permite compartirlo con seguridad entre threads. Los records lo hacen idiomático (new Config(existing.name(), newTimeout)), y se ha debatido una sintaxis estilo with para records en el lenguaje, pero no está en Java 21.
Cuando de verdad hacen falta copias profundas de grafos mutables —un modelo de documento, el estado de un juego—, un constructor de copia por clase que copie a sus hijos es más predecible que clone(), y serializar a bytes y volver a deserializar es un último recurso lento y frágil.
Cómo elegir entre ellos
| Patrón | Úsalo cuando | Sustituto moderno |
|---|---|---|
| Singleton | Una única instancia de un recurso global al proceso, sin contenedor | Scope singleton de un contenedor DI; enum para objetos tipo constante |
| Factory Method | Las subclases o quien llama eligen el producto concreto | Parámetro Supplier<T> / Function; static factory con switch |
| Abstract Factory | Varios productos deben venir de una misma familia coherente | Perfiles de Spring y clases de configuración |
| Builder | Muchos parámetros, valores opcionales, validación entre campos, inmutabilidad | Record + constructor compacto para tipos pequeños con todo obligatorio |
| Prototype | Copia con cambios o construcción costosa | Constructores de copia y withers sobre tipos inmutables |
Errores comunes
El Singleton como variable global. Estado estático mutable accesible desde cualquier parte, imposible de testear y propenso a race conditions. Inyéctalo.
Singleton perezoso mal hecho. Double-checked locking sin volatile, o una simple comprobación de null sin sincronizar, crea dos instancias bajo carga. Usa un enum o el holder.
Factory que solo envuelve new. Un ThingFactory.create() que devuelve new Thing() sin ningún punto de variación es pura ceremonia. Añade el patrón cuando exista una segunda implementación o una regla de construcción que centralizar.
Producto mutable después de build(). Un builder cuyo producto sigue teniendo setters pierde la inmutabilidad que lo justificaba.
Builder sin validación. build() es el único lugar que ve todos los campos; comprueba ahí los invariantes entre campos.
clone() en clases con campos mutables. La copia superficial comparte las partes mutables: cada copia modifica las listas de la otra. Copia de forma explícita.
La reflexión que burla a un Singleton. Un constructor privado puede invocarse por reflexión; la forma enum es la única que la JVM protege, y también la única que sobrevive a la serialización como instancia única.
Puntos clave
- Los patrones creacionales desacoplan el código de las clases concretas y centralizan las reglas de construcción; el static factory method es la pieza básica de la mayoría.
- Implementa un Singleton como enum o con el holder de inicialización bajo demanda, y prefiere el scope singleton de un contenedor DI para que la dependencia sea visible y testeable.
- Factory Method delega la elección del producto en una subclase o, en el Java moderno, en un
Supplier/Functionque se pasa como parámetro; Abstract Factory hace lo mismo para una familia de productos que deben encajar. - Builder es la respuesta a los muchos parámetros opcionales: valores con nombre en la llamada, inmutabilidad y un solo lugar para los valores por defecto y la validación.
- En Java, Prototype significa constructores de copia y withers sobre tipos inmutables, no
Cloneable. - Aplica un patrón cuando la variación que permite existe de verdad; un patrón con una sola implementación es indirección sin beneficio.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Es el patrón Singleton un antipatrón?
- La versión escrita a mano con acceso global suele serlo: oculta dependencias, hace que los tests compartan estado y es difícil de reemplazar. La idea de una única instancia por aplicación está bien, y es justo lo que te da un bean de Spring con scope singleton, inyectado en lugar de obtenido. Escribe una clase Singleton solo para recursos realmente globales al proceso en código que no tenga contenedor.
- ¿Qué diferencia hay entre Factory Method y Abstract Factory?
- Factory Method es un único método sobrescribible que crea un producto y deja que las subclases decidan la clase concreta. Abstract Factory es un objeto cuyos métodos crean una familia de productos relacionados que deben usarse juntos, como los widgets de un mismo look-and-feel o los drivers de una misma base de datos. Las abstract factories suelen implementarse con factory methods.
- ¿Cuándo debo usar un Builder en lugar de un constructor?
- Cuando un tipo tiene más de tres o cuatro parámetros, varios de ellos opcionales, o cuando la validación tiene que considerar varios campos a la vez. Un builder mantiene inmutable el resultado, da nombre a cada valor en la llamada y evita la explosión de constructores telescópicos. Para un valor pequeño con todos los campos obligatorios, el constructor canónico de un record es más sencillo.
- ¿Debo usar clone() para el patrón Prototype?
- Mejor un constructor de copia o un método estático de copia. Object.clone es protected, exige la interfaz marcadora Cloneable, hace por defecto una copia superficial y se salta los constructores, así que los invariantes y los campos final se vuelven incómodos. Un constructor de copia es explícito, profundo donde tú decidas y funciona con campos final.