MSC (Model-Service-Controller) es un patrón útil para mantener la lógica de negocio separada del manejo HTTP y el acceso a datos. El model es dueño del schema. El controller es dueño del request/response. El service es dueño de todo lo que está en el medio. Lo que el patrón no dice es cómo organizar la propia capa de servicios. ¿Deberías crear un servicio por model? ¿Agrupar servicios por dominio u objetivo? ¿Usar una mezcla? Esa pregunta tiene una respuesta concreta, y depende de lo que tu aplicación realmente hace.

Entendiendo MSC en el Ecosistema de Software Moderno

En la arquitectura MSC:

  • Los models encapsulan schemas de datos y objetos de negocio.
  • Los services contienen la lógica de negocio o de dominio que opera sobre los models.
  • Los controllers manejan las solicitudes entrantes, llaman a los servicios y retornan respuestas.

La capa de servicios es donde residen las decisiones de diseño reales. Una capa de servicios bien estructurada mantiene el código navegable a medida que la aplicación crece, evita que la lógica se filtre hacia los controllers y hace que los servicios individuales sean testeables de forma aislada. TypeScript agrega tipado fuerte, interfaces y generics que hacen que estos límites sean más fáciles de imponer y más difíciles de violar accidentalmente.

El Desafío de Estructurar Servicios

Existen tres enfoques principales, cada uno con un tradeoff diferente.

Un servicio por model es fácil de visualizar. UserService maneja usuarios, ProductService maneja productos, OrderService maneja pedidos. Para aplicaciones pequeñas u operaciones CRUD simples, este es el punto de partida correcto. El riesgo es que la lógica transversal —como enviar un correo de confirmación tras colocar un pedido— termine duplicada entre servicios o encajada forzosamente en el servicio que maneja la acción disparadora.

Servicios organizados por objetivo agrupa la lógica alrededor de operaciones de dominio en lugar de entidades de datos. En vez de un ProductService y un OrderService separados, podrías tener un CheckoutService que maneje todo el flujo de compra. Esto se alinea bien con la forma en que el negocio piensa sobre sus operaciones y mantiene los pasos relacionados juntos. El riesgo es que un servicio de dominio puede absorber demasiadas responsabilidades si su alcance no está claramente definido, volviéndose tan enredado como el controller que debía reemplazar.

Un enfoque híbrido usa servicios centrados en models para CRUD directo y servicios centrados en dominio para operaciones que abarcan múltiples models. Un UserService maneja la creación y recuperación de usuarios. Un AnalyticsService agrega datos de múltiples servicios de model para producir reportes. Este es el enfoque que escala mejor, pero requiere un juicio continuo sobre dónde trazar las líneas.

Un Servicio por Model: Pros y Contras

Las principales ventajas son la simplicidad y la descubribilidad. Cada desarrollador sabe inmediatamente dónde buscar la lógica relacionada con usuarios. Los nuevos miembros del equipo pueden orientarse rápidamente. Para aplicaciones más pequeñas o microservicios individuales con responsabilidades acotadas, este enfoque no tiene desventajas significativas.

Los problemas aparecen cuando las operaciones abarcan múltiples models. Las notificaciones por correo electrónico, los cálculos de impuestos, el logging de auditoría y otras preocupaciones transversales terminan duplicadas en cada servicio que las necesita, o se convierten en métodos del servicio que pareció más conveniente en ese momento. Ninguno de los dos resultados es limpio.

Un servicio centrado en model en TypeScript:

// models/User.ts
export interface User {
  id: string;
  name: string;
  email: string;
}

// services/UserService.ts
import { User } from '../models/User';

export class UserService {
  private users: User[] = [];

  public createUser(userData: Omit<User, 'id'>): User {
    const newUser: User = {
      id: Math.random().toString(36).substring(2),
      ...userData
    };
    this.users.push(newUser);
    return newUser;
  }

  public getUserById(userId: string): User | undefined {
    return this.users.find(user => user.id === userId);
  }

  // Lógica CRUD adicional, si es necesario
}

UserService tiene un único trabajo: gestionar datos de usuarios. Eso es todo lo que debería hacer.

Servicios Orientados a Objetivos: Adoptando el Domain-Driven Design

Los servicios orientados a objetivos tienen sentido cuando las operaciones significativas de tu sistema involucran naturalmente múltiples models trabajando juntos. Un checkout de e-commerce involucra un usuario, un pedido, un pago y un correo electrónico. Dividir esa lógica entre cuatro servicios de model separados significa que el flujo de checkout vive en cuatro lugares, lo que lo hace más difícil de entender y de modificar.

// domain/EcommerceService.ts
import { User } from '../models/User';
import { Order } from '../models/Order';
import { EmailService } from './EmailService';
import { PaymentGateway } from './PaymentGateway';

export class EcommerceService {
  constructor(
    private emailService: EmailService,
    private paymentGateway: PaymentGateway
  ) {}

  public async processOrder(user: User, order: Order): Promise<void> {
    // Ejecutar pago
    if (await this.paymentGateway.charge(user, order.total)) {
      // Guardar pedido en la base de datos o store en memoria
      // ...alguna lógica aquí

      // Enviar correo de confirmación
      await this.emailService.sendEmail(user.email, 'Order Confirmed', 'Your order has been processed successfully!');
    }
  }
}

EcommerceService orquesta la secuencia completa del checkout. EmailService y PaymentGateway se inyectan como dependencias, manteniendo su lógica separada y testeable. El servicio de dominio permanece enfocado en la orquestación, no en los detalles de implementación del pago o el correo.

El riesgo a vigilar: un servicio que empieza manejando pedidos, luego absorbe la lógica del catálogo de productos, luego la gestión de inventario, luego las devoluciones. En ese punto ya no es un servicio de dominio enfocado; es un acumulador genérico que necesita dividirse.

La Ruta Híbrida: Lo Mejor de Ambos Mundos

El enfoque híbrido usa servicios de model para CRUD directo y servicios de dominio para operaciones transversales. Los servicios de model manejan entidades individuales de forma limpia. Los servicios de dominio llaman a los servicios de model para obtener datos y luego ejecutan operaciones de nivel superior sobre ellos.

El AnalyticsService a continuación es un buen ejemplo: no posee datos directamente, pero extrae información de múltiples servicios de model para calcular métricas agregadas.

// services/AnalyticsService.ts
import { UserService } from './UserService';
import { OrderService } from './OrderService';

export class AnalyticsService {
  constructor(
    private userService: UserService,
    private orderService: OrderService
  ) {}

  public async getAverageSpendingPerUser(): Promise<number> {
    const users = this.userService.getAllUsers();
    let totalSpending = 0;
    for (const user of users) {
      const orders = this.orderService.getOrdersByUserId(user.id);
      const userSpending = orders
        .map(order => order.total)
        .reduce((acc, cur) => acc + cur, 0);
      totalSpending += userSpending;
    }
    return totalSpending / users.length;
  }
}

AnalyticsService tiene límites claros: lee datos a través de los servicios existentes y calcula un resultado. No crea ni actualiza nada. Esa claridad lo hace sencillo de testear y fácil de razonar.

Consejos para Evolucionar tu Arquitectura MSC

Comienza con un servicio por model y divide solo cuando un servicio esté haciendo demasiadas cosas o cuando la lógica transversal esté siendo duplicada. La abstracción prematura hacia servicios de dominio crea complejidad antes de que sepas lo que el dominio realmente necesita.

El sistema de tipos de TypeScript ayuda a imponer los límites de los servicios. Define interfaces para lo que cada servicio acepta y retorna. Usa clases abstractas o interfaces para hacer explícitos los puntos de inyección. Los generics pueden reducir el boilerplate para servicios que hacen cosas similares en diferentes tipos de model.

En arquitecturas serverless, cada función Lambda frecuentemente mapea directamente a una operación de servicio. La misma disciplina de límites de servicio aplica: cada función debe tener un trabajo claro. Los message brokers (AWS SNS/SQS, RabbitMQ, Kafka) manejan la comunicación entre servicios sin acoplamiento fuerte.

Cuando un servicio de dominio se vuelve grande, divídelo de forma incremental. Extrae una responsabilidad a la vez, verifica que nada se rompa y luego continúa. Una reorganización a gran escala hecha de una sola vez es más difícil de revisar y tiene mayor probabilidad de introducir bugs.

Por Qué Importa la Organización Adecuada de los Servicios

A medida que una aplicación escala, la capa de servicios es donde se acumula la mayor parte de la complejidad. Una mala organización de servicios lleva a lógica duplicada, propiedad poco clara y servicios difíciles de testear porque dependen de demasiadas cosas. Una buena organización significa que cada servicio tiene un trabajo claro, sus dependencias son explícitas y los tests para él no requieren que toda la aplicación esté en ejecución.

Para los equipos, los límites claros de servicio reducen la sobrecarga de coordinación. Los desarrolladores front-end, los ingenieros backend y DevOps pueden trabajar en diferentes partes del código sin pisarse constantemente. El onboarding es más rápido porque la estructura indica dónde pertenece cada cosa.

El patrón también rinde sus frutos durante la depuración. Cuando algo falla en producción, una capa de servicios bien organizada hace que sea mucho más fácil rastrear qué servicio es responsable del fallo y con qué datos estaba trabajando.

Conclusión

La organización correcta de los servicios depende de lo que hace tu aplicación. Un servicio por model es el punto de partida correcto para la mayoría de las aplicaciones. Agrega servicios orientados a dominio cuando las operaciones abarcan consistentemente múltiples models y el código para esas operaciones sigue dispersándose. El enfoque híbrido es el estándar práctico para aplicaciones de tamaño significativo.

Cualquiera que sea la estructura que elijas, mantén los límites de los servicios explícitos, inyecta dependencias en lugar de importarlas directamente y no esperes hasta que un servicio sea un desastre para dividirlo. Cuanto antes notes que un servicio está acumulando responsabilidades que no debería tener, más fácil será la refactorización.