Responsabilidad Única: El principio que se paga solo

architectureprinciplessolid
Responsabilidad Única: El principio que se paga solo

El coste oculto de "hace demasiadas cosas"

Al principio de mi carrera construía controladores que validaban input, consultaban la base de datos, aplicaban reglas de negocio, llamaban a APIs externas y formateaban la respuesta -- todo en una función. Funcionaba. Hasta que un cambio en la integración con Stripe rompió la validación, y una actualización del logging introdujo una regresión en el formato de respuesta. Todo estaba acoplado, así que cada cambio era una apuesta.

Después de más de diez años entregando sistemas en producción, me quedo con una regla simple: cada pieza del sistema hace una sola cosa bien. Cuando algo necesita cambiar, sabes exactamente dónde ir -- y nada más se rompe.

Esto no es teoría. Voy a mostrarlo con código real de una plataforma SaaS que he construido.

Capa 1: Middleware que resuelve una sola cosa

El backend tiene una carpeta middlewares/. Cada archivo hace exactamente un trabajo. Sin excepciones.

// blockSuspiciousRequests.ts -- bloquea escaneos de vulnerabilidades
export const blockSuspiciousRequests = (
  req: Request, res: Response, next: NextFunction
): void => {
  const path = req.path.toLowerCase();
 
  const hasBlockedExtension = BLOCKED_EXTENSIONS.some((ext) =>
    path.endsWith(ext)
  );
  const hasBlockedPathPrefix = BLOCKED_PATH_PREFIXES.some((blocked) =>
    path.startsWith(blocked)
  );
 
  if (hasBlockedExtension || hasBlockedPathPrefix) {
    logger.warn(`Blocked suspicious request: ${req.method} ${req.path}`);
    res.status(403).send("Forbidden");
    return;
  }
 
  next();
};
// logRequest.ts -- registra peticiones entrantes, nada más
export function logRequest(req: Request, res: Response, next: NextFunction) {
  const skipPaths = ["/webhooks/whatsapp", "/healthz"];
  if (skipPaths.includes(req.path)) return next();
 
  logger.info(`Request: ${req.method} ${req.originalUrl}`, {
    query: req.query, params: req.params, body: req.body,
  });
  next();
}
// errorHandler.ts -- transforma errores en respuestas HTTP
export const errorHandler = (
  error: Error | ApiError | ZodApiError,
  req: Request, res: Response, next: NextFunction
) => {
  if (error instanceof ZodApiError) {
    res.status(error.statusCode || 400).json({
      status: "error",
      message: error.message,
      errors: error.errors.map((err) => ({
        field: err.field, message: err.message,
      })),
    });
    return;
  }
  // ... maneja ApiError, Error genérico
};

Tres archivos, tres responsabilidades: filtrado de seguridad, registro de peticiones, formato de errores. Cuando el equipo de seguridad me pide bloquear un nuevo patrón de ruta, abro blockSuspiciousRequests.ts. No toco el logging. No toco el manejo de errores. El radio de impacto de cada cambio es exactamente un archivo.

Capa 2: Las rutas declaran, los controladores orquestan

Las rutas son pura declaración -- qué método HTTP, qué path, qué middleware, qué método del controlador. No contienen lógica de negocio.

// billing/routes.ts
export const billingRoutes = (router: Router) => {
  const billingController = new BillingController();
 
  router.post("/billing/checkout-session",
    isClientRoute,
    billingController.createCheckoutSession
  );
 
  router.get("/:businessId/billing/subscription-info",
    isBusinessRoute,
    billingController.getSubscriptionInfo
  );
 
  router.get("/:businessId/billing/payment-methods",
    isBusinessRoute,
    billingController.getPaymentMethods
  );
};

El archivo de rutas responde una sola pregunta: "qué endpoints existen y quién puede acceder?" Eso es todo. El middleware isClientRoute gestiona la autenticación. El middleware isBusinessRoute gestiona la autorización a nivel de negocio. El controlador gestiona la petición en sí.

Esta separación significa que puedo añadir un nuevo endpoint de billing con una línea en el archivo de rutas, sin tener que leer cientos de líneas de lógica de request para encontrar dónde encajarlo.

Capa 3: Los controladores validan y delegan

Los controladores se sientan entre HTTP y la lógica de negocio. Su trabajo: validar la petición entrante, llamar al servicio correcto y formatear la respuesta. Nunca contienen reglas de negocio.

// billing/controller.ts
async createCheckoutSession(req: Request, res: Response, next: NextFunction) {
  try {
    const validatedData = createCheckoutSessionSchema.parse(req.body);
    const { _id: clientId } = getClientUserFromRequest(req);
 
    const clientAndBusiness = await this.clientApi.getClientAndBusiness(clientId);
    if (!clientAndBusiness) {
      return res.status(404).json({ success: false, message: "Client not found" });
    }
 
    const result = await this.billingApi.createCheckoutSession({
      packageId: validatedData.packageId,
      client: clientAndBusiness.client,
      business: clientAndBusiness.business[0],
      successUrl: validatedData.successUrl,
      cancelUrl: validatedData.cancelUrl,
    });
 
    res.status(201).json({
      success: true,
      data: {
        sessionId: result.sessionId,
        checkoutUrl: result.checkoutUrl,
        packageInfo: result.packageInfo,
      },
    });
  } catch (error) {
    if (error instanceof ZodError) {
      return res.status(400).json({ success: false, errors: error.errors });
    }
    next(error);
  }
}

Fíjate en lo que este controlador NO hace: no calcula precios, no habla directamente con Stripe, no decide qué cupón aplicar. Valida, delega en billingApi y formatea. Si la API de Stripe cambia, el controlador no cambia. Si el esquema de validación cambia, la integración con Stripe no se ve afectada.

Capa 4: Lógica de dominio aislada

Las reglas de negocio viven en sus propios archivos, libres de conceptos HTTP y detalles de base de datos.

// challenges/domain/createChallenge.ts
export function createEmptyChallengeContent({
  name, businessId, businessSlug, locale,
}: {
  name: string;
  businessId: string;
  businessSlug: string;
  locale: Locale;
}): IChallengeContent {
  return {
    _id: new ObjectId().toString(),
    businessId,
    name,
    reward: emptyReward,
    isActive: true,
    publicUrl: `${businessSlug}/${sanitizeUrlSlug(name)}`,
    blocks: [],
    title: locale.split("-")[0] === "es"
      ? "Título de Ejemplo"
      : "Example Title",
    config: { locale, automaticValidation: false, uniqueParticipation: false },
  };
}

Esta función no sabe nada de Express, nada de MongoDB, nada de códigos de estado HTTP. Recibe datos planos y devuelve datos planos. Puedo testearla con una simple llamada a función. Puedo reutilizarla desde un script CLI, un job de migración o un endpoint de API. Solo tiene una razón para cambiar: cuando cambien las reglas de negocio para crear challenges.

El beneficio compuesto

La carpeta de middleware tiene siete archivos. Cada uno tiene menos de 100 líneas. Cuando incorporo a un nuevo desarrollador, le digo: "Cada middleware hace una sola cosa -- el nombre del archivo te dice cuál." Son productivos desde el primer día.

middlewares/
  blockSuspiciousRequests.ts  -- filtrado de seguridad
  errorHandler.ts             -- formato de errores
  isBusinessRoute.ts          -- autorización de negocio
  isClientRoute.ts            -- autenticación de cliente
  isFromOurServer.ts          -- auth servidor a servidor
  isSuperAdminRoute.ts        -- autorización de admin
  logRequest.ts               -- registro de peticiones

Siete responsabilidades, siete archivos, cero solapamiento. Cuando algo falla en autenticación, abro un archivo. Cuando necesitamos un nuevo nivel de autorización, creo un archivo. El sistema crece por adición, no por modificación.

El mismo patrón se repite en cada capa: los archivos de rutas declaran endpoints, los controladores validan y delegan, las funciones de dominio encapsulan reglas, y los servicios compartidos como R2Client o EventBus son dueños de una única responsabilidad de infraestructura.

La conclusión práctica

Responsabilidad única no va de escribir funciones pequeñas. Va de hacer que cada cambio sea predecible. Cuando el product manager dice "cambia el flujo de billing," sé que voy a tocar billingApi y el controlador de billing. Cuando la auditoría de seguridad dice "bloquea uploads de SVG," sé que voy a tocar una sola whitelist de content-types. Cuando el nuevo desarrollador pregunta "dónde se gestiona la autenticación," le señalo un único archivo.

El principio se paga solo la primera vez que hay un incidente en producción a las 2 de la mañana y encuentras el problema en menos de cinco minutos porque el error te dice exactamente qué capa falló, y esa capa hace exactamente una cosa.

Construye sistemas donde el nombre del archivo te diga la responsabilidad. Tu yo del futuro te lo agradecerá.

Sobre mí

Escrito por Fran Llantada — desarrollador full-stack en Nieve Consulting. En mi tiempo libre construí Cliencer, un SaaS completo desde cero, solo. Estos artículos son las lecciones de ingeniería que fui sacando en el camino.