Límites claros: el principio de arquitectura que te permite desplegar más rápido

architectureprinciplesdomain-driven
Límites claros: el principio de arquitectura que te permite desplegar más rápido

El problema del código enredado

Al principio de un proyecto, parece más rápido poner todo junto. La lógica de autenticación accede directamente a facturación. Facturación consulta la tabla de juegos. Analytics lee de todas las colecciones. Antes de darte cuenta, cambiar un flujo de suscripción requiere tocar archivos en seis directorios, y nadie tiene confianza en que el deploy no va a romper algo que no tiene nada que ver.

Vi este patrón en múltiples proyectos. El costo no se nota al principio, pero se acumula: onboarding más lento, deploys más riesgosos, y equipos que se pisan constantemente.

La solución no es un framework complejo. Es una decisión estructural: cada área del negocio es dueña de su propio código, datos y reglas.

Cómo se ven los límites claros en la práctica

En Cliencer, una plataforma SaaS que construí, el backend está organizado alrededor de dominios de negocio. No alrededor de capas técnicas como "controllers", "services" y "models" en directorios planos. Cada dominio es un módulo autocontenido:

back-cliencer/src/app/
  analytics/       # Tracking de eventos y reportes
  auth/            # Autenticación y login
  billing/         # Integración con Stripe, suscripciones, pagos
  business/        # Gestión de la entidad negocio
  challenges/      # Contenido de desafíos
  clients/         # Gestión de usuarios cliente
  crm/             # Campañas de email, WhatsApp, mensajes de onboarding
  games/           # Lógica de juegos y configuración
  webhooks/        # Integraciones con servicios externos
  ...

Cada módulo tiene la misma estructura interna: una clase API (la interfaz pública), un controller (capa HTTP), rutas, tipos de dominio y un repositorio. La parte crítica es lo que está entre módulos: la clase API actúa como el contrato de frontera.

Anatomía de un módulo

Toma el módulo de billing. Su estructura interna te dice todo sobre lo que posee y cómo interactúas con él:

billing/
  BillingApi.ts          # Interfaz pública -- otros módulos llaman a esto
  controller.ts          # Handlers HTTP -- clientes externos llaman aquí
  routes.ts              # Definiciones de rutas
  domain/
    index.ts             # Tipos de dominio (interfaces, enums)
    planDisplayNames.ts  # Lógica de negocio específica de billing
  stripeService/
    StripeService.ts     # Integración con terceros (encapsulada)
    interfaces.ts        # Tipos específicos de Stripe
  schemas/               # Validación de requests
  __tests__/             # Tests del módulo

La BillingApi es la única clase que otros módulos deberían importar. Expone operaciones como createCheckoutSession, getSubscriptionInfo y getBillingHistory. La integración con Stripe queda detrás -- nadie más en el sistema sabe ni le importa que usamos Stripe.

export class BillingApi {
  private stripeService: StripeService;
  private businessApi: BusinessApi;
  private clientApi: ClientApi;
  private crmApi: CrmApi;
 
  async createCheckoutSession(
    params: CreateCheckoutSessionParams
  ): Promise<CheckoutSessionResult> {
    // Toda la complejidad de Stripe vive acá
  }
 
  async getSubscriptionInfo(
    business: IBusiness,
    stripeCustomerId: string | null
  ): Promise<ISubscriptionInfoResponse> {
    // Combina datos del plan local con estado de Stripe
  }
}

Cuando el módulo de billing necesita datos del negocio, los pide a través de BusinessApi. Nunca consulta la colección de businesses directamente. Esta es la disciplina central: comunicarse a través de interfaces públicas, no a través de acceso compartido a la base de datos.

Cada dominio define sus propios tipos

El módulo de billing define sus propios tipos de dominio que representan conceptos de facturación:

// billing/domain/index.ts
export interface ISubscriptionInfoResponse {
  plan: {
    name: string;
    displayName: string;
    status: "active" | "inactive" | "expired" | "trial";
    endDate: string;
    daysRemaining: number | null;
  };
  billing: {
    hasPaymentMethod: boolean;
    stripeCustomerId: string | null;
  };
}
 
export type StripeSubscriptionStatus =
  | "incomplete"
  | "incomplete_expired"
  | "trialing"
  | "active"
  | "past_due"
  | "canceled"
  | "unpaid"
  | "paused"
  | "none";

El módulo de analytics tiene su propia jerarquía de tipos, completamente separada:

// analytics/domain/types.ts
export type BusinessEventType =
  | "business_created"
  | "game_activated"
  | "payment_succeeded"
  | "image_created"
  | "owner_logged_in"
  | "first_game_created"
  | ...;
 
export type InfluencerEventType =
  | "game_visited"
  | "game_completed"
  | "coupon_generated"
  | "coupon_redeemed";
 
export interface EventQueryFilters {
  businessId: string;   // Requerido -- aislamiento multi-tenant
  type?: EventType | EventType[];
  gameId?: string;
  from?: Date;
  to?: Date;
}

Observa cómo analytics incluso separa sus propias preocupaciones internas: los eventos de negocio y los eventos de influencer viven en repositorios distintos con esquemas distintos. La clase AnalyticsApi provee una interfaz unificada mientras mantiene los datos subyacentes separados:

export class AnalyticsApi {
  private businessRepo: BusinessEventsRepository;
  private influencerRepo: InfluencerEventsRepository;
 
  async trackBusinessEvent(event): Promise<string> {
    return this.businessRepo.save(event);
  }
 
  async trackInfluencerEvent(event): Promise<string> {
    return this.influencerRepo.save(event);
  }
 
  async getCombinedTimeline(businessId, options): Promise<AnalyticsEvent[]> {
    const [businessEvents, influencerEvents] = await Promise.all([
      this.businessRepo.getTimeline(businessId, options),
      this.influencerRepo.getTimeline(businessId, options),
    ]);
    return [...businessEvents, ...influencerEvents]
      .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
  }
}

Dos stores de datos separados, una API limpia. El consumidor nunca necesita saber sobre la separación interna.

Las rutas refuerzan los límites

El registro de rutas hace el límite explícito también a nivel HTTP. Cada módulo es dueño de su prefijo de ruta, y el router principal simplemente los compone:

// server/registerRoutes.ts
export const registerRoutes = (router: Router) => {
  authRoutes(router);
  clientRoutes(router);
  businessRoutes(router);
  billingRoutes(router);
  challengeRoutes(router);
  gameRoutes(router);
  webhookRoutes(router);
  // ...cada módulo registra sus propias rutas
};

Dentro de cada módulo, las rutas están acotadas a su dominio. Las rutas de billing viven todas bajo /billing/* o /:businessId/billing/*. Games bajo /games/*. Sin contaminación cruzada.

La comunicación entre módulos va por APIs

Cuando el controller de billing necesita saber sobre un negocio, no importa el repositorio de business. Usa la API pública:

export class BillingController {
  private billingApi: BillingApi;
  private clientApi: ClientApi;
  private businessApi: BusinessApi;
 
  async getSubscriptionInfo(req, res, next) {
    const business = await this.businessApi.getBusinessById(businessId);
    const subscriptionInfo = await this.billingApi.getSubscriptionInfo(
      business, stripeCustomerId
    );
    res.json({ success: true, data: subscriptionInfo });
  }
}

Cuando el módulo de business necesita trackear un evento de analytics, lo hace a través de AnalyticsApi:

// Dentro de BusinessApi.createNewBusiness()
const analyticsApi = new AnalyticsApi();
await analyticsApi.trackBusinessEvent({
  type: "business_created",
  businessId: business._id,
  clientId: ownerUserId,
  payload: { businessName: business.name, plan: business.plan.name },
});

El módulo de business no sabe qué colección usa analytics, qué índices tiene, ni cómo se estructuran los eventos internamente. Solo conoce la interfaz pública.

Por qué esto importa para el negocio

Esto no es purismo arquitectónico. Los límites claros producen tres resultados de negocio concretos:

Evolución independiente. Cuando necesité agregar el flujo de smart checkout con detección de estado de suscripción, los cambios estuvieron completamente dentro del módulo de billing. Business, analytics y games quedaron intactos. El riesgo del deploy estuvo contenido.

Onboarding más seguro. Un nuevo desarrollador trabajando en lógica de juegos nunca necesita entender los webhooks de Stripe. La superficie que necesita aprender está limitada al módulo en el que trabaja.

Trabajo en paralelo. Dos desarrolladores pueden trabajar en billing y analytics simultáneamente sin conflictos de merge, porque los módulos solo comparten interfaces, no implementaciones.

Conclusión práctica

No necesitas una arquitectura de microservicios para tener límites claros. Un monolito bien estructurado con APIs a nivel de módulo logra el mismo aislamiento con mucha menos complejidad operativa.

La regla es simple: cada módulo expone una clase API pública. Otros módulos importan esa clase y nada más. Los tipos de dominio se definen localmente. Los repositorios son privados. Las integraciones con terceros están encapsuladas.

Empieza trazando los límites alrededor de tus dominios de negocio -- no alrededor de capas técnicas. Después refuérzalos con disciplina de imports. El resultado es una base de código donde cada área puede cambiar de forma independiente, desplegarse con seguridad, y escalar su equipo sin overhead de coordinación.

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.