Compartir código entre aplicaciones usando Yarn Symlinks: Una alternativa simple a los monorepos complejos

Compartir código entre aplicaciones usando Yarn Symlinks: Una alternativa simple a los monorepos complejos

¿Te has encontrado alguna vez duplicando utilidades, tipos de datos o componentes entre diferentes aplicaciones? Si trabajas en múltiples proyectos que necesitan compartir código común, seguramente has enfrentado el dilema de cómo gestionar este código compartido de manera eficiente.

Recientemente, mientras exploraba diferentes enfoques para compartir código entre aplicaciones, me topé con una solución elegante y simple que muchos desarrolladores pasan por alto: los symlinks de Yarn. Esta técnica puede ser la respuesta perfecta si buscas una alternativa liviana a las configuraciones complejas de monorepos.

El Problema: Código Duplicado Everywhere

Imagina que tienes tres aplicaciones: un frontend React, una API Node.js y una aplicación móvil React Native. Todas necesitan:

  • Validadores comunes
  • Tipos TypeScript compartidos
  • Utilidades para formatear fechas
  • Funciones de validación de email
  • Constantes de la aplicación

El enfoque tradicional sería:

  1. 📁 Copiar y pegar el código en cada proyecto
  2. 📦 Publicar paquetes npm separados
  3. 🏗️ Configurar un monorepo complejo con Lerna o Rush

Los problemas son evidentes:

  • 🔴 Duplicación: El mismo código en múltiples lugares
  • 🔴 Inconsistencias: Versiones diferentes del mismo código
  • 🔴 Mantenimiento: Actualizar en múltiples lugares
  • 🔴 Complejidad: Configuraciones pesadas para problemas simples

Los symlinks de Yarn ofrecen una solución intermedia elegante. Con un simple comando, puedes enlazar código local entre proyectos:

yarn add link:/ruta/a/la/carpeta/local

Este comando crea un enlace simbólico a un paquete que está en tu sistema de archivos local, perfecto para desarrollar paquetes relacionados en entornos de monorepo.

✅ Es Ideal Si:

  • Buscas una solución simple para compartir código
  • Usualmente liberas tus aplicaciones juntas (ej: frontend + backend)
  • Quieres desarrollo en tiempo real sin builds complejos
  • Tienes un equipo pequeño trabajando en proyectos relacionados

❌ No Es La Mejor Opción Si:

  • Quieres publicar paquetes independientemente
  • Necesitas bloquear versiones específicas del código compartido
  • Trabajas con equipos distribuidos con ciclos de release diferentes
  • Requieres versionado semántico estricto

Configuración Paso a Paso

Estructura del Proyecto

Vamos a crear un monorepo simple con dos aplicaciones y una biblioteca compartida:

monorepo-ejemplo/
├── shared-lib/
│   ├── package.json
│   ├── yarn.lock
│   └── src/
│       ├── index.ts
│       ├── utils/
│       └── types/
├── app-frontend/
│   ├── package.json
│   ├── yarn.lock
│   └── src/
├── app-backend/
│   ├── package.json
│   ├── yarn.lock
│   └── src/
└── README.md

Paso 1: Crear la Biblioteca Compartida

mkdir monorepo-ejemplo && cd monorepo-ejemplo
mkdir shared-lib && cd shared-lib
npm init -y

Crear la estructura básica:

// shared-lib/src/index.ts
export * from './utils/validators';
export * from './utils/formatters';
export * from './types/common';

// shared-lib/src/utils/validators.ts
export const isValidEmail = (email: string): boolean => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
};

export const isValidPhone = (phone: string): boolean => {
  const phoneRegex = /^\+?[\d\s\-\(\)]+$/;
  return phoneRegex.test(phone);
};

// shared-lib/src/utils/formatters.ts
export const formatCurrency = (amount: number, currency = 'EUR'): string => {
  return new Intl.NumberFormat('es-ES', {
    style: 'currency',
    currency,
  }).format(amount);
};

export const formatDate = (date: Date): string => {
  return new Intl.DateTimeFormat('es-ES').format(date);
};

// shared-lib/src/types/common.ts
export interface User {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
}

export interface ApiResponse<T> {
  success: boolean;
  data?: T;
  error?: string;
}

Configurar el package.json:

{
  "name": "shared-lib",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}

Paso 2: Configurar TypeScript

// shared-lib/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Paso 3: Crear las Aplicaciones

cd ..
mkdir app-frontend && cd app-frontend
npm init -y

cd ..
mkdir app-backend && cd app-backend
npm init -y

Paso 4: Enlazar la Biblioteca Compartida

Desde cada aplicación, ejecuta:

# En app-frontend/
cd app-frontend
yarn add link:../shared-lib

# En app-backend/
cd ../app-backend
yarn add link:../shared-lib

Esto agregará shared-lib como dependencia en ambos package.json:

{
  "dependencies": {
    "shared-lib": "link:../shared-lib"
  }
}

Paso 5: Usar el Código Compartido

// app-frontend/src/components/UserForm.tsx
import React, { useState } from 'react';
import { isValidEmail, User } from 'shared-lib';

export const UserForm: React.FC = () => {
  const [email, setEmail] = useState('');
  const [isValid, setIsValid] = useState(false);

  const handleEmailChange = (value: string) => {
    setEmail(value);
    setIsValid(isValidEmail(value));
  };

  return (
    <form>
      <input
        type="email"
        value={email}
        onChange={(e) => handleEmailChange(e.target.value)}
        style={{ borderColor: isValid ? 'green' : 'red' }}
      />
      <p>Email {isValid ? 'válido' : 'inválido'}</p>
    </form>
  );
};
// app-backend/src/controllers/userController.ts
import { isValidEmail, User, ApiResponse } from 'shared-lib';

export class UserController {
  async createUser(userData: any): Promise<ApiResponse<User>> {
    if (!isValidEmail(userData.email)) {
      return {
        success: false,
        error: 'Email inválido'
      };
    }

    // Lógica de creación de usuario...
    const user: User = {
      id: generateId(),
      email: userData.email,
      name: userData.name,
      createdAt: new Date()
    };

    return {
      success: true,
      data: user
    };
  }
}

Flujo de Desarrollo Local

Desarrollo en Tiempo Real

Para que los cambios en shared-lib se reflejen automáticamente:

# Terminal 1: Compilar la biblioteca en modo watch
cd shared-lib
yarn build --watch

# Terminal 2: Desarrollar app-frontend
cd app-frontend
yarn dev

# Terminal 3: Desarrollar app-backend
cd app-backend
yarn dev

Limitaciones con Create React App

Si usas Create React App, necesitarás algunas configuraciones adicionales porque CRA no detecta cambios fuera de su carpeta src:

# Instalar CRACO para personalizar webpack
npm install @craco/craco --save-dev
// craco.config.js
const path = require('path');

module.exports = {
  webpack: {
    configure: (webpackConfig) => {
      // Permitir imports desde fuera de src/
      webpackConfig.resolve.plugins = webpackConfig.resolve.plugins.filter(
        plugin => plugin.constructor.name !== 'ModuleScopePlugin'
      );
      
      // Añadir shared-lib a los directorios observados
      webpackConfig.watchOptions = {
        ignored: /node_modules\/(?!shared-lib)/,
      };
      
      return webpackConfig;
    },
  },
};

Integración Continua

En CI/CD, necesitas construir la biblioteca compartida antes que las aplicaciones:

# .github/workflows/deploy.yml
name: Build and Deploy
on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      
      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '18'
          
      # 1. Construir shared-lib primero
      - name: Build shared library
        working-directory: ./shared-lib
        run: |
          yarn install
          yarn build
          
      # 2. Construir frontend
      - name: Build frontend
        working-directory: ./app-frontend
        run: |
          yarn install  # Automáticamente enlaza shared-lib
          yarn build
          
      # 3. Construir backend
      - name: Build backend
        working-directory: ./app-backend
        run: |
          yarn install
          yarn build

Script de Automatización

Para simplificar el desarrollo, puedes crear scripts que gestionen todo:

// package.json (raíz del monorepo)
{
  "name": "monorepo-ejemplo",
  "scripts": {
    "install:all": "yarn install:shared && yarn install:frontend && yarn install:backend",
    "install:shared": "cd shared-lib && yarn install",
    "install:frontend": "cd app-frontend && yarn install",
    "install:backend": "cd app-backend && yarn install",
    "build:all": "yarn build:shared && yarn build:frontend && yarn build:backend",
    "build:shared": "cd shared-lib && yarn build",
    "build:frontend": "cd app-frontend && yarn build",
    "build:backend": "cd app-backend && yarn build",
    "dev:shared": "cd shared-lib && yarn dev",
    "dev:frontend": "cd app-frontend && yarn dev",
    "dev:backend": "cd app-backend && yarn dev",
    "link:all": "cd app-frontend && yarn add link:../shared-lib && cd ../app-backend && yarn add link:../shared-lib"
  },
  "devDependencies": {
    "concurrently": "^7.6.0"
  }
}

Para desarrollo paralelo:

{
  "scripts": {
    "dev:all": "concurrently \"yarn dev:shared\" \"yarn dev:frontend\" \"yarn dev:backend\""
  }
}

Ventajas y Desventajas

✅ Ventajas

  1. Simplicidad: Configuración mínima comparado con monorepos complejos
  2. Desarrollo en tiempo real: Cambios instantáneos entre proyectos
  3. Sin duplicación: Un solo lugar para el código compartido
  4. Flexibilidad: Cada proyecto mantiene su independencia
  5. Sin overhead: No necesitas herramientas adicionales complejas

❌ Desventajas

  1. Versionado limitado: No puedes bloquear versiones específicas
  2. Frágil: Cambios en shared-lib pueden romper todas las apps
  3. CI/CD más complejo: Necesitas construir shared-lib primero
  4. Limitaciones con algunas herramientas: Como Create React App
  5. No escalable: Para equipos grandes puede ser problemático

Cuándo Evolucionar

Hacia Yarn Workspaces

Si notas que tienes muchas dependencias duplicadas:

// package.json (raíz)
{
  "private": true,
  "workspaces": [
    "shared-lib",
    "app-frontend", 
    "app-backend"
  ]
}

Hacia Lerna

Si necesitas publicar paquetes independientemente:

npm install --global lerna
lerna init
lerna bootstrap
lerna version
lerna publish

Hacia Rush o Nx

Para equipos grandes con necesidades complejas de build y testing.

Casos de Uso Reales

Startup Tech Stack

startup-monorepo/
├── shared-core/          # Tipos, validadores, constantes
├── web-app/             # React frontend
├── mobile-app/          # React Native
├── api-server/          # Node.js API
└── admin-panel/         # Admin React app

Microservicios con Código Compartido

microservices-suite/
├── shared-types/        # TypeScript interfaces
├── shared-utils/        # Utilidades comunes
├── user-service/        # Microservicio de usuarios
├── payment-service/     # Microservicio de pagos
└── notification-service/ # Microservicio de notificaciones

Mejores Prácticas

1. Estructura Clara de la Biblioteca Compartida

// shared-lib/src/index.ts
// Exportaciones organizadas por módulo
export * from './validation';
export * from './formatting';
export * from './types';
export * from './constants';
export * from './utils';

2. Versionado Semántico Manual

Aunque no uses npm publish, mantén un changelog:

# CHANGELOG.md

## [1.2.0] - 2024-07-15
### Added
- Nueva función formatCurrency
- Validador isValidPhone

### Changed
- isValidEmail ahora es más estricto

### Breaking Changes
- Renombrado User.createdDate a User.createdAt

3. Testing del Código Compartido

// shared-lib/package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch"
  }
}

4. Documentación Clara

/**
 * Valida si un email tiene formato correcto
 * @param email - El email a validar
 * @returns true si el email es válido
 * @example
 * ```typescript
 * isValidEmail('user@example.com') // true
 * isValidEmail('invalid-email') // false
 * ```
 */
export const isValidEmail = (email: string): boolean => {
  // ...
};

Conclusión

Los Yarn symlinks ofrecen una solución elegante y simple para compartir código entre aplicaciones relacionadas. No es la solución perfecta para todos los casos, pero puede ser ideal si buscas:

  • Una alternativa liviana a configuraciones complejas de monorepo
  • Desarrollo en tiempo real entre proyectos relacionados
  • Simplicidad sobre funcionalidades avanzadas
  • Una solución intermedia antes de evolucionar a herramientas más complejas

¿Cuándo considerar esta solución?

  • Equipos pequeños (2-5 desarrolladores)
  • Proyectos relacionados con ciclos de release similares
  • Necesidad de compartir código sin complejidad adicional
  • Como paso intermedio hacia un monorepo más sofisticado

La clave está en entender que no existe una solución única para todos los casos. Los Yarn symlinks pueden ser perfectos para tu situación actual, y siempre puedes evolucionar hacia herramientas más complejas cuando tu proyecto lo requiera.

¿Has usado symlinks en tus proyectos? ¿Qué estrategias usas para compartir código entre aplicaciones? ¡Comparte tu experiencia en los comentarios!


Recursos adicionales:

Relacionados