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.