Patrones de Arquitectura y Diseño Modernos: Mi Enfoque como Desarrollador Full Stack
Como desarrollador full stack enfocado en React/Next.js y Node.js, he comprobado que la diferencia entre un sistema mantenible y uno que se convierte en una pesadilla técnica suele depender de las decisiones arquitectónicas que tomamos desde el inicio. Los patrones de diseño y arquitectura no son solo teoría: son herramientas que, bien aplicadas, definen la calidad, escalabilidad y claridad de un proyecto.
En este artículo comparto los patrones que realmente han funcionado para mí en el ecosistema moderno de JavaScript y TypeScript. No se trata de seguir modas, sino de aplicar soluciones que resuelvan problemas reales con elegancia y eficiencia.
Patrones de Diseño: Herramientas para problemas específicos
Patrones Creacionales: Controlamos la creación de objetos
Singleton: Mi aliado para conexiones globales
El patrón Singleton se basa en una idea simple pero poderosa: garantizar que una clase tenga una única instancia y proporcionar un punto de acceso global a ella. Es especialmente útil cuando trabajamos con recursos compartidos que deben mantenerse consistentes en toda la aplicación, como conexiones a bases de datos, configuraciones globales o servicios de logging.
En mi caso, lo uso para asegurar que la conexión a la base de datos se inicialice una sola vez, evitando duplicaciones innecesarias y posibles conflictos. Aquí tienes cómo lo implemento:
// database/DatabaseConnection.ts
export class DatabaseConnection {
private static instance: DatabaseConnection
private connection: any
private constructor() {
// Configuración privada para evitar instanciación directa
this.connection = this.initializeConnection()
}
public static getInstance(): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection()
}
return DatabaseConnection.instance
}
private initializeConnection() {
// Lógica de conexión a la base de datos
return {/* conexión real */}
}
public getConnection() {
return this.connection
}
}
// Uso en cualquier parte de la aplicación
const db = DatabaseConnection.getInstance()
const connection = db.getConnection()
Factory: Creación flexible de componentes
Es una solución elegante para crear objetos o componentes de forma dinámica, según condiciones o parámetros específicos. En React, puede ser útil cuando necesitamos renderizar distintos tipos de componentes en función de props, sin tener que escribir múltiples condicionales en el JSX principal.
La idea detrás del patrón Factory es encapsular la lógica de creación en una función o clase, lo que mejora la organización del código y facilita la escalabilidad. Aunque no lo he implementado aún en proyectos reales, este ejemplo muestra cómo podría usarse para generar componentes de forma flexible:
// components/ComponentFactory.tsx
import { ReactElement, PropsWithChildren } from "react"
import Button from "./Button"
import Input from "./Input"
import Card from "./Card"
export type ComponentType = "button" | "input" | "card"
export interface BaseProps {
type: ComponentType
[key: string]: any
}
type ComponentFactoryProps = PropsWithChildren
export function ComponentFactory({
type,
children,
...restProps
}: ComponentFactoryProps): React.JSX.Element {
switch (type) {
case "button":
return
case "input":
return
case "card":
return {children}
default:
throw new Error(`Tipo de componente desconocido: ${type}`)
}
}
// Uso en componentes
Click me
Patrones Estructurales: Gestionando relaciones entre objetos
Adapter: Integrando APIs incompatibles
Se utiliza para traducir la interfaz de una clase o estructura externa a otra que se ajuste a nuestras necesidades internas. Es especialmente útil cuando trabajamos con APIs de terceros que no siguen el mismo formato o convención que nuestro sistema. Este patrón tiene mucho sentido en proyectos donde se consumen servicios externos y necesitamos mantener consistencia en los modelos de datos. El siguiente ejemplo muestra cómo podríamos adaptar una respuesta externa a nuestra estructura interna:
// adapters/ExternalApiAdapter.ts
interface ExternalUser {
user_name: string
user_email: string
user_age: number
}
interface InternalUser {
name: string
email: string
age: number
}
export class UserAdapter {
static adaptExternalUser(externalUser: ExternalUser): InternalUser {
return {
name: externalUser.user_name,
email: externalUser.user_email,
age: externalUser.user_age
}
}
static adaptInternalUser(internalUser: InternalUser): ExternalUser {
return {
user_name: internalUser.name,
user_email: internalUser.email,
user_age: internalUser.age
}
}
}
// Uso cuando consumo APIs externas
const externalUser: ExternalUser = await fetchExternalUser()
const adaptedUser = UserAdapter.adaptExternalUser(externalUser)
Patrones de Comportamiento: Gestionando algoritmos y responsabilidades
Proxy: Control de acceso y operaciones
El patrón Proxy actúa como un intermediario entre el consumidor y el objeto real, permitiendo interceptar, modificar o controlar el acceso a ciertas operaciones. Es especialmente útil cuando queremos añadir lógica adicional, como caching, validaciones, logging o control de permisos, sin alterar el objeto original.
Este patrón puede aplicarse para optimizar llamadas a APIs, proteger recursos sensibles o incluso simular respuestas en entornos de testing. A continuación, te muestro un ejemplo de cómo se podría implementar un proxy para manejar caching automático en peticiones HTTP:
// proxies/ApiProxy.ts
interface ApiResponse {
data: any
status: number
}
export class ApiProxy {
private cache: Map = new Map()
constructor(private apiService: any) {}
async get(url: string): Promise {
if (this.cache.has(url)) {
console.log("Retornando respuesta de cache")
return this.cache.get(url)!
}
const response = await this.apiService.get(url)
this.cache.set(url, response)
return response
}
}
// Uso para caching automático
const apiProxy = new ApiProxy(apiService)
const data = await apiProxy.get("/users") // Llama a la API real
const cachedData = await apiProxy.get("/users") // Devuelve del cache
Patrones de Arquitectura: La base de todo sistema
Arquitectura MVC (Modelo-Vista-Controlador)
Consiste en dividir la aplicación en tres capas bien definidas:
Es un enfoque clásico que sigue siendo útil en proyectos Express.js donde se renderizan vistas directamente desde el servidor. Aunque en mi caso prefiero separar el frontend en otro proyecto, especialmente cuando uso React o Next.js, reconozco que MVC puede ser práctico en aplicaciones monolíticas, sistemas administrativos o MVPs donde la simplicidad y rapidez de desarrollo son clave.
Aquí tienes una estructura básica que representa bien este patrón:
src/
├── controllers/
│ ├── UserController.ts
├── models/
│ ├── UserModel.ts
├── views/
│ ├── user/
│ │ ├── profile.ejs
├── routes/
│ ├── userRoutes.ts
// controllers/UserController.ts
export class UserController {
static async getProfile(req: Request, res: Response) {
try {
const user = await UserModel.findById(req.params.id)
res.render("user/profile", { user })
} catch (error) {
res.status(500).json({ error: "Error interno del servidor" })
}
}
}
Arquitectura de Microservicios
El patrón de microservicios propone dividir una aplicación en servicios independientes, cada uno con su propia lógica, base de datos y ciclo de despliegue. Esta arquitectura permite escalar de forma granular, distribuir responsabilidades entre equipos y mejorar la resiliencia del sistema.
Aunque aún no he implementado microservicios en producción, suelo estructurar mis proyectos de forma modular, separando funcionalidades en servicios independientes que bien podrían evolucionar hacia microservicios. Esta separación me ayuda a mantener el código limpio, testeable y fácil de escalar.
Un ejemplo básico de cómo podría estructurarse esta arquitectura sería:
// services/auth-service/src/index.ts
import express from "express"
import authRoutes from "./routes/auth"
const app = express()
app.use("/auth", authRoutes)
// services/user-service/src/index.ts
import express from "express"
import userRoutes from "./routes/users"
const app = express()
app.use("/users", userRoutes)
Arquitectura Monolítica vs Distribuida
La elección entre una arquitectura monolítica y una distribuida depende del alcance del proyecto, del equipo y de los objetivos a largo plazo. En mi experiencia, ambas tienen su lugar, y entender cuándo aplicar cada una es parte esencial del trabajo arquitectónico.
Monolito: Perfecto para proyectos pequeños o MVPs
La arquitectura monolítica agrupa el frontend, backend y lógica compartida en un solo repositorio. Es ideal para proyectos pequeños, MVPs o entornos donde la velocidad de desarrollo y la simplicidad son prioridad.
// Ejemplo de estructura monolítica
project/
├── src/
│ ├── frontend/ # React/Next.js
│ ├── backend/ # Express API
│ ├── shared/ # Utilidades compartidas
Este enfoque facilita el desarrollo inicial, reduce la complejidad de despliegue y permite compartir código fácilmente entre capas. Sin embargo, a medida que el proyecto crece, puede volverse difícil de mantener, escalar o distribuir entre equipos.
Distribuida: Mi preferida para proyectos serios
Cuando el proyecto requiere escalabilidad, independencia entre equipos o despliegues separados, prefiero una arquitectura distribuida. Separar el frontend y el backend en repositorios distintos permite mayor flexibilidad, control de versiones independiente y una mejor gestión de responsabilidades.
// Estructura distribuida (repositorios separados)
frontend-repo/ # React/Next.js application
backend-repo/ # Express API
Este enfoque es especialmente útil en productos digitales con ciclos de desarrollo paralelos, donde el frontend evoluciona a su ritmo y el backend puede escalar o dividirse en servicios más especializados.
Arquitectura Basada en Componentes
Una de las prácticas que más disfruto aplicar en React es la arquitectura basada en componentes. No solo mejora la organización del código, sino que también facilita la escalabilidad, el testing y la colaboración entre desarrolladores. En mis proyectos, llevo este enfoque al extremo: cada pieza de la interfaz, por pequeña que sea, tiene su propio espacio, su propia lógica y su propio ciclo de vida.
La idea es que cada componente sea una unidad autónoma, con su propia implementación, pruebas, documentación y estilos. Esto permite reutilizar, versionar y mantener el sistema con mayor facilidad. Aquí tienes una estructura que refleja cómo suelo organizar mis componentes:
// src/components/
├── ui/ # Componentes puros de UI
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx
│ │ ├── Button.stories.tsx
│ │ └── index.ts
├── forms/ # Componentes de formulario
├── layout/ # Componentes de layout
└── features/ # Componentes con lógica específica
Este tipo de organización me permite trabajar con claridad y velocidad. Por ejemplo, si necesito modificar el botón principal del sistema, sé exactamente dónde está su lógica, sus pruebas y su documentación visual (via Storybook). Además, separar los componentes por dominio, como forms, layout o features, ayuda a mantener el enfoque y evitar acoplamientos innecesarios.
Back for Frontend (BFF): Mi patrón secreto para el rendimiento
Consiste en crear una capa intermedia entre el frontend y los servicios backend, diseñada específicamente para las necesidades de la interfaz.
En un proyecto con Next.js donde el frontend y backend convivían en el mismo entorno, implementé este patrón mediante un Server Action. Desde ahí, llamaba a mi API interna sin exponer directamente los endpoints al cliente. Esta estrategia me permitió encapsular la lógica, proteger rutas sensibles y entregar los datos justo como los necesitaba el componente.
A continuación, un ejemplo básico de cómo podría estructurarse un BFF con Express para evitar que el cliente realice múltiples llamadas a distintos servicios:
// services/bff-gateway/src/index.ts
import express from "express"
import { userService, orderService, productService } from "./clients"
const app = express()
app.get("/user-dashboard/:userId", async (req, res) => {
try {
const [user, orders, recommendations] = await Promise.all([
userService.getUser(req.params.userId),
orderService.getUserOrders(req.params.userId),
productService.getRecommendations(req.params.userId)
])
res.json({
user,
orders,
recommendations
})
} catch (error) {
res.status(500).json({ error: "No se pudo obtener los datos del dashboard" })
}
})
Reflexiones finales: Cómo elijo el patrón adecuado
Bonus: Mi stack técnico ideal para la mayoría de proyectos
A lo largo del tiempo, he afinado una combinación de herramientas y enfoques que me permiten construir sistemas robustos, escalables y mantenibles. Este es el stack que suelo recomendar y aplicar, a nivel de patrones y arquitectura: