Esta cuarta entrega explora los patrones estructurales: maneras de organizar y conectar clases y objetos para construir sistemas flexibles y reutilizables. Verás cómo adaptar interfaces, separar abstracción de implementación, componer jerárquicamente, extender comportamiento sin herencia y optimizar memoria, con ejemplos claros en Java 25.
Patrones de Diseño de Estructura
Los patrones estructurales se centran en cómo las clases y objetos se componen para formar estructuras más grandes. Utilizan herencia y composición para crear nuevas funcionalidades a partir de las existentes.
Adapter
El patrón Adapter permite que interfaces incompatibles trabajen juntas convirtiendo la interfaz de una clase en otra que el cliente espera. Existen dos variantes principales: el adaptador de clase (que usa herencia múltiple donde el lenguaje lo permite) y el adaptador de objeto (que usa composición).
El adaptador de clase hereda de la clase adaptada e implementa la interfaz objetivo, permitiendo modificar el comportamiento heredado. El adaptador de objeto contiene una instancia de la clase adaptada y delega las llamadas, lo que permite adaptar múltiples clases y cambiar dinámicamente entre ellas. La cantidad de trabajo que realiza el adaptador depende de qué tan diferentes sean las interfaces.
Ejemplo: Adaptador para integración de sistemas de pago
// Interfaz objetivo que nuestro sistema esperapublic interface PaymentProcessor { PaymentResult process(PaymentRequest request); PaymentStatus checkStatus(String transactionId); RefundResult refund(String transactionId, Money amount);}
public record PaymentRequest( String customerId, Money amount, CardInfo card, String description) {}
public record PaymentResult( String transactionId, PaymentStatus status, Instant processedAt, String message) {}
public enum PaymentStatus { PENDING, COMPLETED, FAILED, REFUNDED }
// Librería externa con interfaz incompatible (no podemos modificarla)public class StripeClient { public StripeCharge createCharge(String apiKey, StripeChargeRequest request) { // Implementación de Stripe return new StripeCharge(); }
public StripeCharge retrieveCharge(String apiKey, String chargeId) { return new StripeCharge(); }
public StripeRefund createRefund(String apiKey, String chargeId, long amountCents) { return new StripeRefund(); }}
public class StripeChargeRequest { private long amount; private String currency; private String source; private String description; // getters, setters...}
// Adaptador de objeto - adapta StripeClient a PaymentProcessorpublic class StripePaymentAdapter implements PaymentProcessor {
private final StripeClient stripeClient; private final String apiKey;
public StripePaymentAdapter(String apiKey) { this.stripeClient = new StripeClient(); this.apiKey = apiKey; }
@Override public PaymentResult process(PaymentRequest request) { // Convertir nuestro request al formato de Stripe var stripeRequest = new StripeChargeRequest(); stripeRequest.setAmount(request.amount().toCents()); stripeRequest.setCurrency(request.amount().currency()); stripeRequest.setSource(createStripeToken(request.card())); stripeRequest.setDescription(request.description());
try { StripeCharge charge = stripeClient.createCharge(apiKey, stripeRequest);
// Convertir respuesta de Stripe a nuestro formato return new PaymentResult( charge.getId(), mapStripeStatus(charge.getStatus()), Instant.ofEpochSecond(charge.getCreated()), charge.getOutcomeMessage() ); } catch (StripeException e) { return new PaymentResult( null, PaymentStatus.FAILED, Instant.now(), e.getMessage() ); } }
@Override public PaymentStatus checkStatus(String transactionId) { StripeCharge charge = stripeClient.retrieveCharge(apiKey, transactionId); return mapStripeStatus(charge.getStatus()); }
@Override public RefundResult refund(String transactionId, Money amount) { StripeRefund refund = stripeClient.createRefund(apiKey, transactionId, amount.toCents()); return new RefundResult(refund.getId(), refund.getStatus().equals("succeeded")); }
private PaymentStatus mapStripeStatus(String stripeStatus) { return switch (stripeStatus) { case "succeeded" -> PaymentStatus.COMPLETED; case "pending" -> PaymentStatus.PENDING; case "refunded" -> PaymentStatus.REFUNDED; default -> PaymentStatus.FAILED; }; }
private String createStripeToken(CardInfo card) { // Crear token de tarjeta para Stripe return "tok_" + card.number().substring(card.number().length() - 4); }}
// Uso - el cliente trabaja con la interfaz unificadapublic class CheckoutService { private final PaymentProcessor paymentProcessor;
public CheckoutService(PaymentProcessor paymentProcessor) { this.paymentProcessor = paymentProcessor; }
public OrderConfirmation checkout(Cart cart, CardInfo card) { var request = new PaymentRequest( cart.customerId(), cart.total(), card, "Order #" + cart.id() );
PaymentResult result = paymentProcessor.process(request);
return switch (result.status()) { case COMPLETED -> new OrderConfirmation(cart.id(), result.transactionId(), true); case PENDING -> throw new PaymentPendingException(result.transactionId()); default -> throw new PaymentFailedException(result.message()); }; }}
// Configuración - elegir adaptador según configuraciónPaymentProcessor processor = switch (config.get("payment.provider")) { case "stripe" -> new StripePaymentAdapter(config.get("stripe.api.key")); case "paypal" -> new PayPalPaymentAdapter(config.get("paypal.client.id"), config.get("paypal.secret")); default -> throw new IllegalStateException("Unknown payment provider");};Bridge
El patrón Bridge desacopla una abstracción de su implementación, permitiendo que ambas varíen independientemente. Este patrón separa una jerarquía de clases en dos jerarquías independientes: una para las abstracciones y otra para las implementaciones.
La abstracción mantiene una referencia a un objeto implementador y delega el trabajo real a este. Esto permite cambiar la implementación en tiempo de ejecución y evita una explosión combinatoria de clases cuando hay múltiples dimensiones de variación. Es especialmente útil cuando se quiere exponer una API pública mientras se mantienen los detalles de implementación internos.
Ejemplo: Sistema de renderizado multiplataforma
// Implementador - define la interfaz de bajo nivelpublic interface RenderingEngine { void drawLine(int x1, int y1, int x2, int y2, Color color); void drawCircle(int x, int y, int radius, Color color, boolean filled); void drawRectangle(int x, int y, int width, int height, Color color, boolean filled); void drawText(String text, int x, int y, Font font, Color color); void clear(); byte[] exportAsImage(String format);}
// Implementaciones concretas para diferentes plataformaspublic class OpenGLRenderer implements RenderingEngine { private final long glContext;
public OpenGLRenderer(int width, int height) { this.glContext = initOpenGL(width, height); }
@Override public void drawLine(int x1, int y1, int x2, int y2, Color color) { glBegin(GL_LINES); glColor4f(color.getRed()/255f, color.getGreen()/255f, color.getBlue()/255f, color.getAlpha()/255f); glVertex2i(x1, y1); glVertex2i(x2, y2); glEnd(); }
@Override public void drawCircle(int x, int y, int radius, Color color, boolean filled) { // Implementación OpenGL para círculos }
// ... otros métodos}
public class SVGRenderer implements RenderingEngine { private final StringBuilder svgContent; private final int width; private final int height;
public SVGRenderer(int width, int height) { this.width = width; this.height = height; this.svgContent = new StringBuilder(); clear(); }
// ... otros métodos}
// Abstracción - define la interfaz de alto nivel para figuraspublic abstract class Shape { protected RenderingEngine renderer; protected Color color; protected int x, y;
protected Shape(RenderingEngine renderer, Color color, int x, int y) { this.renderer = renderer; this.color = color; this.x = x; this.y = y; }
public abstract void draw(); public abstract double area();
public void setRenderer(RenderingEngine renderer) { this.renderer = renderer; }
public void moveTo(int newX, int newY) { this.x = newX; this.y = newY; }}
// Abstracciones refinadas - diferentes tipos de figuraspublic class Circle extends Shape { private final int radius;
public Circle(RenderingEngine renderer, Color color, int x, int y, int radius) { super(renderer, color, x, y); this.radius = radius; }
@Override public void draw() { renderer.drawCircle(x, y, radius, color, true); }
@Override public double area() { return Math.PI * radius * radius; }}
public class Rectangle extends Shape { private final int width; private final int height;
public Rectangle(RenderingEngine renderer, Color color, int x, int y, int width, int height) { super(renderer, color, x, y); this.width = width; this.height = height; }
@Override public void draw() { renderer.drawRectangle(x, y, width, height, color, true); }
@Override public double area() { return width * height; }}
public class Triangle extends Shape { private final int base; private final int height;
public Triangle(RenderingEngine renderer, Color color, int x, int y, int base, int height) { super(renderer, color, x, y); this.base = base; this.height = height; }
@Override public void draw() { // Dibujar triángulo usando líneas int x2 = x + base; int x3 = x + base / 2; int y3 = y - height;
renderer.drawLine(x, y, x2, y, color); renderer.drawLine(x2, y, x3, y3, color); renderer.drawLine(x3, y3, x, y, color); }
@Override public double area() { return (base * height) / 2.0; }}
// Uso del patrón Bridgepublic class DrawingApplication { private final List<Shape> shapes = new ArrayList<>(); private RenderingEngine currentRenderer;
public DrawingApplication(RenderingEngine renderer) { this.currentRenderer = renderer; }
public void addShape(Shape shape) { shapes.add(shape); }
public void switchRenderer(RenderingEngine newRenderer) { this.currentRenderer = newRenderer; // Actualizar todas las figuras al nuevo renderer shapes.forEach(shape -> shape.setRenderer(newRenderer)); }
public void render() { currentRenderer.clear(); shapes.forEach(Shape::draw); }
public byte[] export(String format) { render(); return currentRenderer.exportAsImage(format); }}
// Ejemplo de usovar svgRenderer = new SVGRenderer(800, 600);var app = new DrawingApplication(svgRenderer);
app.addShape(new Circle(svgRenderer, Color.RED, 100, 100, 50));app.addShape(new Rectangle(svgRenderer, Color.BLUE, 200, 200, 150, 100));app.addShape(new Triangle(svgRenderer, Color.GREEN, 400, 300, 100, 80));
// Exportar como SVGbyte[] svgImage = app.export("svg");
// Cambiar a OpenGL para renderizado en pantallaapp.switchRenderer(new OpenGLRenderer(800, 600));app.render();Composite
El patrón Composite permite componer objetos en estructuras de árbol para representar jerarquías parte-todo. Los clientes pueden tratar objetos individuales y composiciones de objetos de manera uniforme a través de una interfaz común.
Este patrón organiza los componentes en hojas (elementos primitivos sin hijos) y composites (elementos que contienen otros componentes). Existe un trade-off entre seguridad de tipos (separando operaciones específicas para hojas y composites) y transparencia (teniendo la misma interfaz para ambos). A partir de componentes simples se pueden construir estructuras complejas de forma elegante.
Ejemplo: Sistema de archivos con Composite
// Composite en React: Componentes de menú anidados
// Componente baseexport function Menu({ children }) { return <ul>{children}</ul>;}
// "Hoja": ítem de menú simpleexport function MenuItem({ label }) { return <li>{label}</li>;}
// "Composite": ítem de menú con hijos (submenú)export function SubMenu({ label, children }) { return ( <li> <span>{label}</span> <ul>{children}</ul> </li> );}
// Ejemplo de uso:export function AppMenu() { return ( <Menu> <MenuItem label="Inicio" /> <MenuItem label="Acerca de" /> <SubMenu label="Productos"> <MenuItem label="Producto A" /> <MenuItem label="Producto B" /> <SubMenu label="Más productos"> <MenuItem label="Producto C" /> </SubMenu> </SubMenu> <MenuItem label="Contacto" /> </Menu> );}Decorator
El patrón Decorator agrega responsabilidades adicionales a un objeto de forma dinámica, proporcionando una alternativa flexible a la herencia para extender funcionalidad. Los decoradores envuelven al objeto original y mantienen su interfaz, permitiendo apilar múltiples decoradores de forma transparente.
Este patrón es ideal cuando se necesita añadir comportamiento a objetos individuales sin afectar a otros objetos de la misma clase. Cada decorador puede ejecutar lógica antes, durante o después de delegar al objeto envuelto. En Java moderno, los decoradores también pueden implementarse elegantemente usando composición de funciones.
Ejemplo: Sistema de procesamiento de texto con decoradores
// Interfaz basepublic interface TextProcessor { String process(String text);}
// Implementación basepublic class PlainTextProcessor implements TextProcessor { @Override public String process(String text) { return text; }}
// Decorador base abstractopublic abstract class TextProcessorDecorator implements TextProcessor { protected final TextProcessor wrapped;
protected TextProcessorDecorator(TextProcessor wrapped) { this.wrapped = Objects.requireNonNull(wrapped); }
@Override public String process(String text) { return wrapped.process(text); }}
// Decoradores concretospublic class UpperCaseDecorator extends TextProcessorDecorator { public UpperCaseDecorator(TextProcessor wrapped) { super(wrapped); }
@Override public String process(String text) { return super.process(text).toUpperCase(); }}
@Slf4jpublic class LoggingDecorator extends TextProcessorDecorator { public LoggingDecorator(TextProcessor wrapped) { super(wrapped); }
@Override public String process(String text) { log.info("Input: " + text.substring(0, Math.min(50, text.length())) + "..."); String result = super.process(text); log.info("Output: " + result.substring(0, Math.min(50, result.length())) + "..."); return result; }}
// Uso con decoradores apiladosfinal TextProcessor processor = new LoggingDecorator( new HtmlEncodingDecorator( new CensoringDecorator( new TrimmingDecorator( new PlainTextProcessor() ), Set.of("spam", "inappropriate") ) ));
final String result = processor.process(" <script>spam content</script> ");// Output: <script>*** content</script>
// Alternativa funcional usando composición de funcionespublic class FunctionalTextProcessor {
public static Function<String, String> trim() { return String::strip; }
public static Function<String, String> toUpperCase() { return String::toUpperCase; }
public static Function<String, String> censor(Set<String> bannedWords) { return text -> { String result = text; for (String word : bannedWords) { result = result.replaceAll("(?i)" + Pattern.quote(word), "***"); } return result; }; }
public static Function<String, String> htmlEncode() { return text -> text .replace("&", "&") .replace("<", "<") .replace(">", ">"); }
@SafeVarargs public static Function<String, String> compose(Function<String, String>... processors) { return Stream.of(processors) .reduce(Function.identity(), Function::andThen); }}
// Uso funcionalfinal var processor = FunctionalTextProcessor.compose( FunctionalTextProcessor.trim(), FunctionalTextProcessor.censor(Set.of("spam")), FunctionalTextProcessor.htmlEncode(), FunctionalTextProcessor.toUpperCase());
final String result = processor.apply(" <spam>hello</spam> ");// Output: <***>HELLO</***>Facade
El patrón Facade proporciona una interfaz unificada y simplificada para un conjunto de interfaces en un subsistema. Reduce la complejidad del sistema al ocultar sus componentes internos detrás de una fachada que expone solo las operaciones más comunes.
Este patrón es útil cuando existe un sistema complejo con múltiples clases interdependientes y se desea proporcionar una forma simple de usarlo para los casos de uso más frecuentes.
Por ejemplo, se desea proveer una fachada de uso de una base de datos relacional, pero se desea ocultar la complejidad del cambio de implementaciones cuando se trabaja con OracleDB vs Autora RDS. Así que se brinda solo una interfaz e internamente se hace los cambios correspondientes dependiendo de qué base de datos se este usando.
La fachada no impide el acceso directo a los componentes del subsistema cuando se necesita funcionalidad avanzada, pero ofrece un punto de entrada conveniente para la mayoría de situaciones.
Ejemplo: Facade para sistema de e-commerce
// Ejemplo de uso de Facade en Laravel
use Illuminate\Support\Facades\DB;use Illuminate\Support\Facades\Cache;use Illuminate\Support\Facades\Mail;
Route::get('/facade-ejemplo', function () { // Acceder a una base de datos (puede ser posgresql, mysql, etc.) usando la fachada DB $users = DB::table('users')->where('active', true)->get();
// Guardar datos temporalmente con la fachada Cache Cache::put('active_users', $users, 60);
// Enviar un correo usando la fachada Mail Mail::raw('Bienvenido!', function ($message) { $message->to('correo@ejemplo.com') ->subject('Saludos desde Laravel'); });
return 'Operaciones realizadas mediante facades.';});
/*Las Facades en Laravel permiten acceder a funcionalidades complejas (bases de datos, cache, email, etc.)a través de una interfaz global y sencilla, usando solo una clase estática como punto de acceso.Esto simplifica el código y elimina la necesidad de instanciar manualmente los servicios o dependencias.De fondo cada una puede usar diferentes servicios (ej: redis, valkey, etc.)*/Flyweight
El patrón Flyweight optimiza el uso de memoria compartiendo eficientemente grandes cantidades de objetos similares. Separa el estado de un objeto en estado intrínseco (compartido, inmutable) y estado extrínseco (único por contexto, proporcionado por el cliente).
Este patrón es aplicable cuando se utilizan muchos objetos cuyo almacenamiento es costoso, la mayoría del estado puede externalizarse, y el uso del patrón reduce significativamente la cantidad de objetos. Un objeto flyweight debe ser indistinguible de uno creado independientemente para cada uso.
Ejemplo: Editor de texto con caracteres compartidos
// Ejemplo: pool de String (flyweight implícito en Java)public class PoolStringFlyweightDemo { public static void main(String[] args) { // Literales: ambos apuntan al mismo objeto en el pool String a = "hola"; String b = "hola"; System.out.println(a == b); // true
// new String: crea un nuevo objeto SÓLO si no se usa intern() String c = new String("hola"); System.out.println(a == c); // false
// intern(): obliga a usar el objeto único del pool (flyweight) String d = c.intern(); System.out.println(a == d); // true
// Siempre que un string sea internado, se comparte la instancia en memoria }}/*En Java, los strings literales y los internados se almacenan en un pool.Esto implementa el patrón flyweight: si dos partes del programa usan el mismo literal o intern(),obtienen la misma referencia inmutable y compartida — ahorrando memoria.Nota: como el resultado de == cambia dependiendo de cómo se creó el Stringsigue siendo mala prácitca en Java comparar dos Strings con ==. Siempre usar método equals*/Proxy
El patrón Proxy proporciona un sustituto o representante de otro objeto para controlar el acceso a este. A diferencia del Adapter que cambia la interfaz, el Proxy implementa la misma interfaz que el objeto real y puede agregar comportamiento adicional como lazy loading, control de acceso, logging o caching.
Existen varios tipos de proxies: virtual (crea objetos costosos bajo demanda), remoto (representa objetos en otro espacio de direcciones), de protección (controla permisos de acceso) y smart reference (realiza operaciones adicionales en cada acceso).
Ejemplo: Proxy con múltiples funcionalidades
// Interfaz comúnpublic interface ImageLoader { Image load(String path); byte[] loadRaw(String path); ImageMetadata getMetadata(String path);}
public record Image(String path, int width, int height, byte[] data) {}public record ImageMetadata(String path, long size, String format, Instant created) {}
// Implementación real (costosa)public class DiskImageLoader implements ImageLoader { // Implementa lógica para cargar imagen desde el disco}
// Proxy virtual con lazy loading y cachépublic class CachingImageProxy implements ImageLoader {
private final ImageLoader realLoader; private final Map<String, Image> imageCache = new ConcurrentHashMap<>(); private final Map<String, ImageMetadata> metadataCache = new ConcurrentHashMap<>(); private final int maxCacheSize;
public CachingImageProxy(ImageLoader realLoader, int maxCacheSize) { this.realLoader = realLoader; this.maxCacheSize = maxCacheSize; }
@Override public Image load(String path) { return imageCache.computeIfAbsent(path, p -> { evictIfNecessary(); return realLoader.load(p); }); }
@Override public byte[] loadRaw(String path) { // Raw siempre va al disco, no se cachea return realLoader.loadRaw(path); }
@Override public ImageMetadata getMetadata(String path) { return metadataCache.computeIfAbsent(path, realLoader::getMetadata); }
private void evictIfNecessary() { if (imageCache.size() >= maxCacheSize) { // Evictar entrada más antigua (simplificado) imageCache.keySet().stream().findFirst().ifPresent(imageCache::remove); } }
public void clearCache() { imageCache.clear(); metadataCache.clear(); }
public int cacheSize() { return imageCache.size(); }}
// Uso combinado de proxies (decorador de proxies)final ImageLoader loader = new CachingImageProxy( new DiskImageLoader(), 100 // max cache size );
// El cliente usa la interfaz sin saber de los proxiesfinal Image img1 = loader.load("/images/photo.png"); // Carga del discofinal Image img2 = loader.load("/images/photo.png"); // Desde caché