Independencia sobre reutilización

architectureprinciplescoupling
Independencia sobre reutilización

La trampa de la reutilización

Al principio de mi carrera, trataba la duplicación de código como el pecado capital. Si dos cosas se parecían, extraía una abstracción compartida. Una clase base. Un módulo utilitario. Un servicio genérico que ambos dominios pudieran importar.

Con los años aprendí que ese instinto -- aunque bienintencionado -- es uno de los errores más caros en arquitectura de software. No porque reutilizar sea malo en sí, sino porque la reutilización prematura crea acoplamiento que al principio es invisible y después es doloroso.

El principio que sigo ahora es simple: independencia sobre reutilización. Si dos módulos se parecen pero sirven a dominios de negocio distintos, los mantengo separados. Hoy pueden verse iguales, mañana pueden divergir, y cuando eso pase, ninguno arrastra al otro.

Cómo se ve esto en código real

Voy a mostrar ejemplos concretos de una plataforma SaaS que construí. El sistema tiene dos funcionalidades principales: Challenges (tareas que los negocios crean para sus clientes) y Games (ruletas de premios, raspaditas, cajas misteriosas). A simple vista, parecería que deberían compartir todo. Ambos tienen un nombre, una configuración, bloques de contenido, una URL pública y un mecanismo de recompensa. Ambos necesitan un repositorio, una capa de servicio, un controlador y schemas de validación.

La tentación de construir un solo ContentModule<T> era real. Me resistí. Acá explico por qué.

Ejemplo 1: Slices verticales independientes

Cada dominio es dueño de toda su pila vertical. Challenge tiene su propio API, controlador, repositorio, tipos de dominio y schemas. Game tiene la misma estructura, completamente separada.

back-cliencer/src/app/
  challenges/
    ChallengeApi.ts
    controller.ts
    repository.ts
    domain/challenge.ts
    schemas/challenge.ts
  games/
    GameApi.ts
    controller.ts
    repository.ts
    domain/game.ts
    schemas/game.ts

Los dos repositorios siguen el mismo patrón -- obtener por ID, listar todos, crear, actualizar, soft-delete:

// ChallengeRepository
async getChallengeContentById(challengeId: string): Promise<IChallengeContent | null> {
  const collection = await this.getChallengeCollection();
  const findResult = await collection.findOne({
    _id: new ObjectId(challengeId),
    archived: { $ne: true },
  });
  if (!findResult) return null;
  return this.mapChallengeToDomain(findResult);
}
 
// GameRepository
async getGameById(gameId: string): Promise<IGameContent | null> {
  const collection = await this.getGameCollection();
  const findResult = await collection.findOne({
    _id: new ObjectId(gameId),
    archived: { $ne: true },
  });
  if (!findResult) return null;
  return mapGameToDomain(findResult);
}

Si, estos métodos se ven casi idénticos. Un caso de manual para extraer un BaseRepository<T>. Pero mira lo que pasó cuando Games necesitó evolucionar por su cuenta: el repositorio de Game ganó existsByPublicUrl() para generar URLs únicas, algo que Challenge nunca necesitó. El mapper de Game maneja styleVariant, brief, ownerApproved y prizesConfigured -- campos que no tienen sentido para Challenges. El mapper de Challenge maneja migraciones de campos legacy (disccountValue a discountValue) que Games nunca tuvo.

Si compartieran una clase base, cada cambio específico de Game requeriría verificar que no rompe Challenges. Al mantenerlos separados, puedo moverme rápido en un dominio sin siquiera mirar el otro.

Ejemplo 2: Mismo concepto, formas distintas

Tanto Challenges como Games recompensan a los usuarios. Pero los modelos de recompensa son fundamentalmente diferentes:

// Challenge: una única recompensa con estructura plana
interface IReward {
  discountValue: number | null;
  discountConditions: string | null;
  pointValue: number | null;
  pointConditions: string | null;
  giftName: string | null;
  giftConditions: string | null;
  generalConditions: string | null;
}
 
// Game: un array de premios probabilísticos
interface IPrize {
  _id: string;
  name: string;
  probability: number;
  type: PrizeType;    // discount | points | gift | nothing
  value: number | null;
  conditions: string | null;
}

Un Challenge tiene una recompensa. Un Game tiene múltiples premios, cada uno con una probabilidad que debe sumar 100%. El schema de actualización de Game incluso valida esto:

// Validación específica de Game: los premios deben sumar 100%
prizes: z.array(prizeSchema).optional().refine(
  (prizes) => {
    if (!prizes || prizes.length === 0) return true;
    const totalProbability = prizes.reduce(
      (sum, prize) => sum + prize.probability, 0
    );
    return Math.abs(totalProbability - 100) < 0.01;
  },
  { message: "La suma de las probabilidades debe ser exactamente 100%" }
),

Si hubiera empezado con una abstracción compartida RewardSystem, la habría construido alrededor del modelo de Challenge (porque fue el primero). Cuando llegaron los Games con premios probabilísticos, habría tenido que forzar el concepto nuevo en la forma vieja, o refactorizar la abstracción compartida en algo lo suficientemente genérico para ambos -- haciéndola más difícil de entender para cualquiera de los dos casos.

Al mantenerlos independientes, cada modelo tiene exactamente la forma que su dominio necesita. Sin adaptadores. Sin generics rebuscados. Sin "este campo solo lo usan los Games, ignóralo para Challenges."

Ejemplo 3: Schemas duplicados que divergen en los bordes

Los dos dominios validan bloques de contenido (texto, link, pregunta, upload). Los schemas de validación de bloques empezaron idénticos:

// Tanto challenges/schemas/block-updates.ts como games/schemas/block-updates.ts
const baseBlockSchema = z.object({
  type: z.enum(["text", "link", "question", "upload", "example", "birthday"]),
});
 
export const textBlockSchema = baseBlockSchema.extend({
  type: z.literal("text"),
  text: z.string().min(0, "Text is required"),
});
 
export const uploadBlockSchema = baseBlockSchema.extend({
  type: z.literal("upload"),
  uploadDescription: z.string().min(1, "Upload description is required"),
});

Hoy, estos archivos son casi idénticos. Pero el schema de actualización de Game ya tiene campos que el de Challenge no: styleVariant, maxAttempts, y la validación de probabilidades en premios. Cuando el dominio de Game necesite una validación de bloques más estricta (por ejemplo, que los bloques de upload en games tengan límites de tamaño de archivo que los challenges no), cambio un archivo. El schema de Challenge queda intacto. Sin necesidad de análisis de riesgo.

Por qué esto importa para el negocio

Esto no es académico. La independencia sobre la reutilización tiene consecuencias concretas para el negocio:

Velocidad de cambio. Cuando necesité agregar todo el sistema de premios probabilísticos a Games, no toqué ni una línea de código de Challenge. Sin tests de regresión contra Challenges. Sin coordinar cambios entre dos features.

Radio de explosión. Un bug en el mapper del repositorio de Game no afecta a Challenges. Cuando corregí el typo legacy disccountValue en el mapper de Challenge, Games no necesitó un deploy.

Reemplazabilidad. Si mañana quiero reescribir Games con una estrategia de base de datos diferente o moverlo a un microservicio separado, nada en Challenges se entera. La frontera ya está limpia.

Carga cognitiva. Cuando abro GameApi.ts, todo lo que necesito para entender Games está ahí. No necesito saltar a un BaseContentApi compartido para descifrar qué métodos están sobreescritos y cuáles son heredados.

Cuándo sí comparto código

No soy dogmático con esto. El codebase sí comparte algunas cosas: las definiciones de tipo IBlock viven en un módulo de dominio compartido porque representan un concepto genuinamente compartido (tanto Challenges como Games usan el mismo sistema de bloques para contenido). La infraestructura como la conexión a MongoDB, el cliente de storage R2 y el logger son compartidos porque no son conceptos de dominio -- son capacidades de plataforma.

La regla general: compartir infraestructura, no lógica de dominio. Si pertenece al negocio, que se quede con su dominio.

La conclusión práctica

La próxima vez que veas dos módulos con código similar y sientas la urgencia de extraer una abstracción compartida, pregúntate: estas cosas cambian por las mismas razones? Si un cambio en uno nunca debería requerir un cambio en el otro, la duplicación no es el problema. El acoplamiento sí lo sería.

Mantené tus módulos independientes. Dejalos que se parezcan. El costo de unas líneas repetidas no es nada comparado con el costo de desenredar una abstracción compartida de la que dos dominios crecieron dependiendo de maneras incompatibles.

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.