Best PracticesTestingDevOpsClean Code

Best Practices: El Manual de Supervivencia del Desarrollador Profesional

Lewis Lopez
Best Practices: El Manual de Supervivencia del Desarrollador Profesional

En 10+ años construyendo sistemas críticos, he aprendido que las best practices no son sugerencias, son la diferencia entre dormir tranquilo o recibir llamadas a las 3 AM.

Este no es un artículo teórico. Son prácticas que uso todos los días en MATIAS IMPULSO y que salvaron millones en proyectos bancarios.

Por Qué las Best Practices No Son Opcionales

Historia Real: El Deploy del Viernes que Costó $200K

Escenario: Fintech con 100K usuarios activos, deploy manual sin tests.

Viernes 4:45 PM:

git push origin main
# "Se ve bien en mi máquina" 🔥

Viernes 5:30 PM:

  • 🔥 API caída
  • 💸 Sistema de pagos offline
  • 📞 1,200 tickets de soporte
  • 😰 3 developers llamados de emergencia

Lunes 9:00 AM:

  • 💰 $200K en compensaciones
  • 📰 Nota de prensa negativa
  • 👥 2 clientes enterprise cancelan

El problema: Zero best practices implementadas.

Las 15 Best Practices No Negociables

1. Testing: Tu Red de Seguridad

Regla de oro: Si no tiene tests, está roto (aún no lo sabes).

// ❌ SIN TESTS: Código frágil
export class PaymentService {
  async processPayment(amount: number, accountId: string): Promise<Payment> {
    const account = await this.accountRepo.findById(accountId);
    
    if (account.balance < amount) {
      throw new InsufficientFundsError();
    }
    
    account.balance -= amount;
    await this.accountRepo.save(account);
    
    return this.createPayment(amount, accountId);
  }
}

// ✅ CON TESTS: Código confiable
describe('PaymentService', () => {
  let service: PaymentService;
  let accountRepo: MockRepository<Account>;
  
  beforeEach(() => {
    accountRepo = createMockRepository();
    service = new PaymentService(accountRepo);
  });
  
  describe('processPayment', () => {
    it('should deduct amount from account balance', async () => {
      // Arrange
      const account = createTestAccount({ balance: 1000 });
      accountRepo.findById.mockResolvedValue(account);
      
      // Act
      await service.processPayment(100, account.id);
      
      // Assert
      expect(account.balance).toBe(900);
      expect(accountRepo.save).toHaveBeenCalledWith(account);
    });
    
    it('should throw InsufficientFundsError when balance is low', async () => {
      // Arrange
      const account = createTestAccount({ balance: 50 });
      accountRepo.findById.mockResolvedValue(account);
      
      // Act & Assert
      await expect(service.processPayment(100, account.id))
        .rejects
        .toThrow(InsufficientFundsError);
      
      expect(accountRepo.save).not.toHaveBeenCalled();
    });
    
    it('should handle account not found', async () => {
      // Arrange
      accountRepo.findById.mockResolvedValue(null);
      
      // Act & Assert
      await expect(service.processPayment(100, 'invalid-id'))
        .rejects
        .toThrow(AccountNotFoundError);
    });
    
    it('should handle concurrent transactions', async () => {
      // Arrange
      const account = createTestAccount({ balance: 1000 });
      accountRepo.findById.mockResolvedValue(account);
      
      // Act - Simular 2 transacciones simultáneas
      const [result1, result2] = await Promise.all([
        service.processPayment(600, account.id),
        service.processPayment(600, account.id),
      ]);
      
      // Assert - Una debe fallar
      const successful = [result1, result2].filter(r => r.status === 'success');
      expect(successful).toHaveLength(1);
    });
  });
});

Pirámide de Testing:

        /\
       /  \  E2E (10%)
      /____\
     /      \  Integration (20%)
    /________\
   /          \  Unit (70%)
  /__________\

Coverage mínimo profesional:

  • ✅ Unit tests: 80%+
  • ✅ Integration tests: 60%+
  • ✅ E2E críticos: 100% de flujos principales

2. Git Flow: Commits que Cuentan Historias

# ❌ MAL: Commits inútiles
git commit -m "fix"
git commit -m "wip"
git commit -m "asdf"
git commit -m "funciona!!!"

# ✅ BIEN: Commits descriptivos (Conventional Commits)
git commit -m "feat(auth): add JWT refresh token rotation"
git commit -m "fix(payments): handle race condition in concurrent transactions"
git commit -m "refactor(user-service): extract validation to separate class"
git commit -m "docs(api): add OpenAPI spec for payment endpoints"
git commit -m "test(orders): add integration tests for order flow"
git commit -m "perf(search): add Redis cache for frequent queries"
git commit -m "chore(deps): upgrade NestJS to v10.3.0"

Convención de commits:

  • feat: Nueva funcionalidad
  • fix: Corrección de bugs
  • refactor: Cambio de código sin cambiar funcionalidad
  • test: Agregar o modificar tests
  • docs: Documentación
  • perf: Mejoras de performance
  • chore: Mantenimiento (deps, configs)
  • ci: Cambios en CI/CD

Branching strategy:

main (production)
  ├── develop (staging)
  │   ├── feature/user-authentication
  │   ├── feature/payment-gateway
  │   └── feature/admin-dashboard
  ├── hotfix/critical-payment-bug
  └── release/v2.1.0

3. Code Reviews: El Filtro de Calidad

Checklist de code review:

// Pull Request Template
## 📝 Description
Brief description of changes and why they're needed.

## 🎯 Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update

## ✅ Checklist
- [ ] Code follows style guidelines
- [ ] Self-review completed
- [ ] Comments added for complex logic
- [ ] Documentation updated
- [ ] Tests added/updated
- [ ] All tests passing
- [ ] No console.logs or debuggers
- [ ] No commented code
- [ ] Performance impact considered

## 🧪 Testing
- [ ] Unit tests added
- [ ] Integration tests added
- [ ] Manual testing completed
- [ ] Edge cases covered

## 📸 Screenshots (if UI changes)

## 🔗 Related Issues
Closes #123

Principios de review:

// ❌ Code review negativo
"Este código es horrible, reescríbelo todo"

// ✅ Code review constructivo
/**
 * Suggestion: Consider extracting this validation logic
 * 
 * Current approach works, but extracting to a dedicated
 * validator class would:
 * - Make it reusable across services
 * - Easier to test in isolation
 * - Follow Single Responsibility Principle
 * 
 * Example:
 * ```typescript
 * class AccountValidator {
 *   validateBalance(account: Account, amount: number): void {
 *     if (account.balance < amount) {
 *       throw new InsufficientFundsError(account.balance, amount);
 *     }
 *   }
 * }
 * ```
 */

4. CI/CD: Deploy con Confianza

Pipeline de MATIAS IMPULSO:

# .github/workflows/ci-cd.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  # 1. QUALITY CHECKS
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run lint
      
  type-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run type-check
  
  # 2. TESTING
  test-unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run test:unit
      - run: npm run test:coverage
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        
  test-integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
      redis:
        image: redis:7
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:integration
  
  # 3. SECURITY
  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm audit --audit-level=high
      - uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
  
  # 4. BUILD
  build:
    needs: [lint, type-check, test-unit, test-integration]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v3
        with:
          name: dist
          path: dist/
  
  # 5. DEPLOY TO STAGING
  deploy-staging:
    needs: build
    if: github.ref == 'refs/heads/develop'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v3
      - name: Deploy to AWS Lambda
        run: |
          aws lambda update-function-code \
            --function-name matias-impulso-staging \
            --zip-file fileb://dist.zip
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET }}
      
      - name: Run smoke tests
        run: npm run test:smoke -- --env=staging
  
  # 6. DEPLOY TO PRODUCTION
  deploy-production:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://matiasimpulso.com
    steps:
      - uses: actions/download-artifact@v3
      
      - name: Deploy to production (blue)
        run: |
          aws lambda update-function-code \
            --function-name matias-impulso-prod-blue \
            --zip-file fileb://dist.zip
      
      - name: Health check blue
        run: npm run health-check -- --env=prod-blue
      
      - name: Switch traffic to blue
        run: |
          aws lambda update-alias \
            --function-name matias-impulso-prod \
            --name live \
            --function-version $BLUE_VERSION
      
      - name: Monitor for 10 minutes
        run: npm run monitor -- --duration=10m
      
      - name: Rollback if errors detected
        if: failure()
        run: |
          aws lambda update-alias \
            --function-name matias-impulso-prod \
            --name live \
            --function-version $GREEN_VERSION

Resultados:

  • Deploy time: 8 minutos (de 45 minutos manual)
  • 🛡️ Zero downtime deploys
  • 🔄 Rollback automático en <30 segundos
  • 📊 Cobertura garantizada: No pasa sin 80%+

5. Logging: Tu Caja Negra

// ❌ Logging amateur
console.log('error');
console.log('user:', user);

// ✅ Logging profesional
import { Logger } from '@nestjs/common';
import { v4 as uuid } from 'uuid';

@Injectable()
export class PaymentService {
  private readonly logger = new Logger(PaymentService.name);
  
  async processPayment(request: PaymentRequest): Promise<Payment> {
    const correlationId = uuid();
    
    this.logger.log({
      message: 'Processing payment',
      correlationId,
      userId: request.userId,
      amount: request.amount,
      currency: request.currency,
    });
    
    try {
      const payment = await this.executePayment(request);
      
      this.logger.log({
        message: 'Payment completed successfully',
        correlationId,
        paymentId: payment.id,
        duration: Date.now() - startTime,
      });
      
      return payment;
      
    } catch (error) {
      this.logger.error({
        message: 'Payment failed',
        correlationId,
        error: error.message,
        stack: error.stack,
        userId: request.userId,
        amount: request.amount,
      });
      
      throw error;
    }
  }
}

// Structured logging con Winston
import winston from 'winston';

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' }),
  ],
});

if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple(),
  }));
}

6. Monitoring: Detecta Problemas Antes que tus Usuarios

// Métricas con Prometheus
import { Counter, Histogram } from 'prom-client';

const paymentCounter = new Counter({
  name: 'payments_total',
  help: 'Total number of payments',
  labelNames: ['status', 'currency'],
});

const paymentDuration = new Histogram({
  name: 'payment_duration_seconds',
  help: 'Payment processing duration',
  buckets: [0.1, 0.5, 1, 2, 5],
});

@Injectable()
export class PaymentService {
  async processPayment(request: PaymentRequest): Promise<Payment> {
    const timer = paymentDuration.startTimer();
    
    try {
      const payment = await this.executePayment(request);
      
      paymentCounter.inc({ 
        status: 'success', 
        currency: request.currency 
      });
      
      return payment;
      
    } catch (error) {
      paymentCounter.inc({ 
        status: 'failed', 
        currency: request.currency 
      });
      
      throw error;
      
    } finally {
      timer();
    }
  }
}

// Alertas en Grafana
// Alert: Payment failure rate > 1%
// Alert: Payment latency p95 > 2s
// Alert: Error rate > 0.5%

7. Error Handling: Falla Graciosamente

// ❌ Error handling amateur
try {
  await doSomething();
} catch (e) {
  console.log('error');
}

// ✅ Error handling profesional

// 1. Jerarquía de errores custom
export abstract class DomainError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly statusCode: number
  ) {
    super(message);
    this.name = this.constructor.name;
  }
}

export class InsufficientFundsError extends DomainError {
  constructor(
    public readonly required: number,
    public readonly available: number
  ) {
    super(
      `Insufficient funds: required ${required}, available ${available}`,
      'INSUFFICIENT_FUNDS',
      400
    );
  }
}

export class PaymentGatewayError extends DomainError {
  constructor(
    message: string,
    public readonly gatewayCode: string
  ) {
    super(message, 'PAYMENT_GATEWAY_ERROR', 502);
  }
}

// 2. Global exception filter
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(GlobalExceptionFilter.name);
  
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();
    
    if (exception instanceof DomainError) {
      this.logger.warn({
        message: 'Domain error',
        code: exception.code,
        error: exception.message,
        path: request.url,
      });
      
      return response.status(exception.statusCode).json({
        statusCode: exception.statusCode,
        code: exception.code,
        message: exception.message,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
    }
    
    // Error inesperado
    this.logger.error({
      message: 'Unexpected error',
      error: exception,
      stack: exception.stack,
      path: request.url,
    });
    
    return response.status(500).json({
      statusCode: 500,
      code: 'INTERNAL_SERVER_ERROR',
      message: 'An unexpected error occurred',
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}

// 3. Retry con exponential backoff
async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  maxRetries: number = 3,
  baseDelay: number = 1000
): Promise<T> {
  let lastError: Error;
  
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      
      if (attempt < maxRetries - 1) {
        const delay = baseDelay * Math.pow(2, attempt);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
  
  throw lastError;
}

// Uso
const payment = await retryWithBackoff(
  () => paymentGateway.charge(amount),
  3,
  1000
);

8. Database Migrations: Evoluciona sin Romper

// TypeORM migrations
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddPaymentStatusIndex1700000000000 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    // 1. Crear índice de forma concurrente (no bloquea)
    await queryRunner.query(`
      CREATE INDEX CONCURRENTLY idx_payments_status 
      ON payments(status) 
      WHERE status IN ('pending', 'processing')
    `);
    
    // 2. Agregar columna con valor default (no requiere rewrite)
    await queryRunner.query(`
      ALTER TABLE payments 
      ADD COLUMN idempotency_key VARCHAR(255)
    `);
    
    // 3. Backfill de datos existentes en batches
    let offset = 0;
    const batchSize = 1000;
    
    while (true) {
      const result = await queryRunner.query(`
        UPDATE payments
        SET idempotency_key = md5(id::text || created_at::text)
        WHERE idempotency_key IS NULL
        LIMIT ${batchSize}
      `);
      
      if (result.affectedRows === 0) break;
      
      await new Promise(resolve => setTimeout(resolve, 100)); // Evita saturar DB
    }
    
    // 4. Hacer columna NOT NULL después del backfill
    await queryRunner.query(`
      ALTER TABLE payments 
      ALTER COLUMN idempotency_key SET NOT NULL
    `);
    
    // 5. Agregar índice único
    await queryRunner.query(`
      CREATE UNIQUE INDEX CONCURRENTLY idx_payments_idempotency 
      ON payments(idempotency_key)
    `);
  }
  
  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP INDEX idx_payments_idempotency`);
    await queryRunner.query(`ALTER TABLE payments DROP COLUMN idempotency_key`);
    await queryRunner.query(`DROP INDEX idx_payments_status`);
  }
}

9. API Versioning: Evoluciona sin Romper Clientes

// ❌ Sin versionado: Breaking changes
@Controller('users')
export class UserController {
  @Get(':id')
  async getUser(@Param('id') id: string) {
    return { id, name: 'Lewis' }; // Cambiamos estructura = clientes rotos
  }
}

// ✅ Con versionado: Backward compatibility
@Controller({ path: 'users', version: '1' })
export class UserControllerV1 {
  @Get(':id')
  async getUser(@Param('id') id: string): Promise<UserV1Response> {
    const user = await this.userService.getUser(id);
    return { id: user.id, name: user.name }; // Formato original
  }
}

@Controller({ path: 'users', version: '2' })
export class UserControllerV2 {
  @Get(':id')
  async getUser(@Param('id') id: string): Promise<UserV2Response> {
    const user = await this.userService.getUser(id);
    return {
      id: user.id,
      profile: {
        firstName: user.firstName,
        lastName: user.lastName,
        email: user.email,
      },
      metadata: {
        createdAt: user.createdAt,
        updatedAt: user.updatedAt,
      },
    };
  }
}

// Cliente decide qué versión usar
// GET /v1/users/123 -> Formato viejo
// GET /v2/users/123 -> Formato nuevo

10. Documentation: Código que se Explica Solo (Casi)

/**
 * Procesa un pago bancario con validaciones completas y retry automático
 * 
 * @param request - Datos del pago a procesar
 * @param request.amount - Monto en centavos (ej: 10000 = $100.00)
 * @param request.currency - Código ISO de moneda (USD, EUR, COP)
 * @param request.accountId - ID de la cuenta a debitar
 * 
 * @returns Payment procesado con ID de transacción
 * 
 * @throws {InsufficientFundsError} Cuando la cuenta no tiene fondos suficientes
 * @throws {AccountNotFoundError} Cuando la cuenta no existe
 * @throws {InvalidAmountError} Cuando el monto es <= 0 o > límite diario
 * @throws {PaymentGatewayError} Cuando el gateway externo falla después de 3 intentos
 * 
 * @example
 * ```typescript
 * const payment = await paymentService.processPayment({
 *   amount: 50000, // $500.00
 *   currency: 'USD',
 *   accountId: 'acc_123',
 * });
 * console.log(payment.id); // "pmt_xyz"
 * ```
 * 
 * @see https://docs.matiasimpulso.com/payments/processing
 */
async processPayment(request: PaymentRequest): Promise<Payment> {
  // Implementation
}

// OpenAPI/Swagger
@ApiTags('payments')
@Controller('payments')
export class PaymentController {
  @Post()
  @ApiOperation({ 
    summary: 'Procesar pago',
    description: 'Procesa un pago bancario con validaciones y retry automático'
  })
  @ApiResponse({ 
    status: 201, 
    description: 'Pago procesado exitosamente',
    type: PaymentResponse 
  })
  @ApiResponse({ 
    status: 400, 
    description: 'Fondos insuficientes o monto inválido' 
  })
  @ApiResponse({ 
    status: 404, 
    description: 'Cuenta no encontrada' 
  })
  @ApiResponse({ 
    status: 502, 
    description: 'Error del gateway de pagos' 
  })
  async processPayment(
    @Body() dto: ProcessPaymentDto
  ): Promise<PaymentResponse> {
    return this.paymentService.processPayment(dto);
  }
}

Caso Real: Best Practices en Banco Estado Chile

Contexto: APIs críticas con SLA 99.99%, 10K TPS.

Best practices implementadas:

  1. Testing: 92% coverage (unit + integration)
  2. CI/CD: 8 deploys diarios sin downtime
  3. Monitoring: Grafana + Prometheus + alertas 24/7
  4. Logging: Structured JSON logs con correlation IDs
  5. Error handling: Retry automático con circuit breaker
  6. Database: Migrations con zero-downtime strategy
  7. API: Versionado + backward compatibility
  8. Security: Snyk + SonarQube + penetration testing
  9. Documentation: OpenAPI + ejemplos + Postman collections
  10. Code reviews: Mandatory, 2 approvals mínimo

Resultados:

  • Uptime: 99.98% (mejor que SLA)
  • MTTR: 4 minutos (de 45 minutos)
  • Bugs en prod: -85%
  • Deploy confidence: 100%

Checklist: ¿Tu Proyecto es Profesional?

🧪 Testing

  • Coverage > 80% (unit + integration)
  • Tests corren en CI/CD
  • Tests son rápidos (<5 min total)
  • Tests no son flaky

🔄 CI/CD

  • Pipeline automatizado completo
  • Deploy automático a staging
  • Approval manual para production
  • Rollback automático si falla health check
  • Zero downtime deploys

📊 Monitoring

  • Métricas de negocio (payments, users, etc.)
  • Métricas técnicas (latency, errors, throughput)
  • Alertas configuradas (<5 min response time)
  • Dashboards en tiempo real

📝 Logging

  • Structured logging (JSON)
  • Correlation IDs en todas las requests
  • Log levels apropiados (ERROR, WARN, INFO, DEBUG)
  • Logs centralizados (CloudWatch, Datadog, etc.)

🐛 Error Handling

  • Errores custom por dominio
  • Global exception filter
  • Retry con exponential backoff
  • Circuit breaker para servicios externos

📚 Documentation

  • README con setup instructions
  • API documentation (OpenAPI/Swagger)
  • Arquitectura documentada (diagrams)
  • Ejemplos de código actualizados

🔒 Security

  • Dependencias escaneadas (Snyk, npm audit)
  • Secrets en variables de entorno (nunca en código)
  • HTTPS everywhere
  • Rate limiting configurado
  • Input validation en todos los endpoints

🗄️ Database

  • Migrations versionadas
  • Backups automáticos
  • Índices en queries frecuentes
  • Connection pooling configurado

Herramientas del Arsenal Profesional

{
  "devDependencies": {
    // Testing
    "jest": "^29.7.0",
    "@testing-library/react": "^14.0.0",
    "supertest": "^6.3.0",
    
    // Quality
    "eslint": "^8.50.0",
    "prettier": "^3.0.0",
    "husky": "^8.0.0",
    "lint-staged": "^14.0.0",
    
    // Type Safety
    "typescript": "^5.3.0",
    "@types/node": "^20.10.0",
    
    // Security
    "snyk": "^1.1200.0",
    
    // Documentation
    "@nestjs/swagger": "^7.1.0",
    
    // Monitoring
    "prom-client": "^15.0.0",
    "winston": "^3.11.0"
  }
}

Conclusión

Best practices no son burocracia. Son la diferencia entre software amateur y software profesional.

Los mejores equipos:

  • 🧪 Testean todo antes de producción
  • 🔄 Automatizan lo repetitivo
  • 📊 Miden antes de optimizar
  • 📝 Documentan para su yo futuro
  • 🛡️ Previenen en vez de apagar incendios

Empieza con una práctica. Domínala. Agrega la siguiente.

No intentes implementar todo de golpe. En MATIAS IMPULSO empezamos con:

  1. Mes 1: Tests básicos + CI/CD simple
  2. Mes 2: Logging estructurado + monitoring básico
  3. Mes 3: Error handling robusto + alertas
  4. Mes 4: Documentation completa + security scans

Hoy tenemos un sistema que funciona 24/7 sin supervisión constante.

Ese es el poder de las best practices.


Lewis Lopez - 10+ años aplicando best practices en producción
Zero incidentes críticos en 18 meses
Fundador de MATIAS IMPULSO

¿Consultas sobre implementación? contacto@lewislopez.io

Compartir este artículo:

¿Te gustó este artículo?

Aprende arquitectura de élite y transforma tu carrera de programador 1x a arquitecto 10x.