Trunk-Based Development: Guía completa para equipos ágiles modernos

Trunk-Based Development: Guía completa para equipos ágiles modernos

Una guía exhaustiva sobre Trunk-Based Development para equipos que trabajan con Scrum: filosofía, implementación práctica con DevOps, gestión de ambientes QA/Staging, y mejores prácticas para desarrollo multi-plataforma.

Por Omar Flores

Imagina un equipo de seis desarrolladores trabajando simultáneamente en una aplicación crítica. Tres están en el backend con Java y Go, dos en el frontend con React, y uno en la aplicación móvil con Flutter. Cada sprint dura dos semanas, tienen que desplegar a producción al final de cada sprint, y necesitan que QA valide todo antes. El escenario tradicional es caótico: ramas de larga duración que divergen, conflictos de merge masivos el último día del sprint, features que se bloquean entre sí, y un tech lead exhausto revisando decenas de pull requests gigantes a última hora.

Ahora imagina el mismo equipo trabajando de manera diferente. Todos integran su código directamente en la rama principal varias veces al día. Los cambios son pequeños, incrementales, y continuamente validados por pipelines automatizados. QA tiene acceso constante a ambientes actualizados. Los conflictos de merge son raros y triviales cuando aparecen. Al final del sprint, no hay drama de integración porque la integración ya ocurrió, continuamente, durante todo el sprint.

Esta es la diferencia entre el desarrollo tradicional basado en ramas de larga duración y Trunk-Based Development. No es solo una técnica de branching de Git. Es una filosofía completa que cambia cómo los equipos ágiles trabajan, colaboran, y entregan software de manera predecible y sostenible.

He trabajado con equipos que han hecho esta transición. He visto sprints que terminaban en caos convertirse en entregas predecibles. He visto la ansiedad del “día de merge” desaparecer. He visto a tech leads recuperar 15 horas semanales que antes gastaban resolviendo conflictos. Y he visto a equipos de QA pasar de validar todo al final a encontrar problemas temprano, cuando son baratos de arreglar.

Pero seamos claros: Trunk-Based Development no es para todos, y no resuelve todos los problemas. Requiere disciplina, automatización sólida, y un cambio cultural. Este artículo es una guía completa sobre qué es realmente Trunk-Based Development, cómo implementarlo en un equipo ágil real, qué problemas resuelve, qué problemas no resuelve, y cómo hacerlo funcionar con tu stack tecnológico, tu proceso de QA, y tu infraestructura de DevOps.


La filosofía fundamental: Por qué existe Trunk-Based Development

Antes de hablar de comandos Git, pipelines de CI/CD, o estrategias de branching, necesitas entender el problema fundamental que Trunk-Based Development resuelve. Y ese problema no es técnico en su esencia, es humano.

El costo invisible de las ramas de larga duración

Durante años, la industria del software adoptó un modelo que parecía lógico en papel: cada desarrollador trabaja en su propia rama, aislado del trabajo de los demás, y cuando termina su feature, lo integra de vuelta a la rama principal. Suena ordenado, ¿verdad? Cada uno en su espacio, sin molestarse mutuamente.

El problema es que este modelo ignora una realidad fundamental: el código no existe en el vacío. Mientras tú trabajas en tu rama durante días o semanas, el resto del equipo está trabajando en las suyas. Cada día que pasa, tu código diverge más del trabajo de tus compañeros. Y cuando finalmente llega el momento de integrar, descubres que:

El contexto cambió: El componente en el que basaste tu trabajo fue refactorizado por otro desarrollador. Las APIs que usas tienen firmas diferentes. Las estructuras de datos evolucionaron en direcciones que no anticipaste. Tu código compila en tu rama, pero no en la rama principal porque el mundo se movió mientras trabajabas aislado.

Los conflictos son complejos: No son solo conflictos sintácticos que Git puede detectar. Son conflictos semánticos: tu código hace suposiciones que ya no son válidas. Llama funciones que ya no existen. Depende de comportamientos que cambiaron. Git no puede ayudarte con esto. Necesitas entender todo el contexto de lo que cambió, por qué cambió, y cómo adaptar tu trabajo.

La integración es un evento: Integrar se convierte en un “momento” en el tiempo, no en un proceso continuo. Y ese momento suele ser estresante, propenso a errores, y consume tiempo masivo. El último día del sprint se convierte en “día de integración” donde todo el equipo está nervioso, tratando de hacer que las piezas encajen.

El feedback es tardío: Trabajaste dos semanas en una dirección. Al integrar descubres que tu enfoque no es compatible con el de otro compañero. Ahora tienes que rehacer trabajo. Si hubieran integrado continuamente, habrían detectado la incompatibilidad en el día uno, cuando corregirla tomaba minutos, no días.

La propuesta de Trunk-Based Development

Trunk-Based Development propone algo radicalmente diferente: todos los desarrolladores trabajan en la misma rama la mayor parte del tiempo. Integran su código a esa rama principal (el “trunk”) con alta frecuencia, típicamente varias veces al día. Los cambios son pequeños, incrementales, y continuamente validados.

Esto invierte completamente la filosofía:

La integración es continua, no un evento: No hay “día de integración”. La integración ocurre constantemente. Cada desarrollador sincroniza con el trunk varias veces al día, descarga los cambios de sus compañeros, y sube sus propios cambios. Los conflictos aparecen temprano, cuando son pequeños y fáciles de resolver.

El feedback es inmediato: Cuando haces un commit al trunk, los tests automatizados se ejecutan inmediatamente. Si algo se rompe, lo sabes en minutos, no en días. Si tu cambio es incompatible con el de un compañero, lo descubres cuando el problema todavía está fresco en tu mente.

El trunk siempre funciona: Porque cada commit pasa por validación automatizada, el trunk está siempre en un estado deployable. Esto no significa que cada feature esté completa, pero sí que el código compila, los tests pasan, y la aplicación funciona. Features incompletas se ocultan detrás de feature flags.

La colaboración es forzada: Cuando todos trabajan en el mismo trunk, no puedes ignorar el trabajo de tus compañeros. Ves sus cambios constantemente. Esto fuerza comunicación, alineación, y decisiones arquitectónicas coherentes.

Los pilares que sostienen la filosofía

Trunk-Based Development no es solo “usar una sola rama”. Se sostiene en tres pilares fundamentales que deben estar presentes para que funcione:

Pilar 1: Automatización exhaustiva

La automatización no es opcional en Trunk-Based Development, es el fundamento. Cuando integras código múltiples veces al día, no puedes permitirte validación manual. Necesitas:

  • Tests automatizados que se ejecutan en cada commit
  • Linters y formatters que aseguran calidad de código
  • Security scans que detectan vulnerabilidades
  • Build pipelines que validan compilación en todos los ambientes
  • Deploy pipelines que pueden llevar código a producción automáticamente

Sin esta automatización, Trunk-Based Development es imposible. La frecuencia de integración colapsaría bajo el peso de validación manual.

Pilar 2: Commits pequeños y frecuentes

Un commit en Trunk-Based Development no es “la feature completa”. Es un paso pequeño y verificable hacia la feature. Puede ser:

  • Agregar una función auxiliar que usarás después
  • Refactorizar un componente para prepararlo para cambios futuros
  • Implementar una pieza de la UI sin conectarla todavía al backend
  • Agregar un endpoint de API sin que nadie lo consuma todavía

Cada commit es deployable en el sentido de que no rompe nada, aunque no agregue valor visible todavía. El valor emerge de la acumulación de estos commits pequeños.

Pilar 3: Feature flags para funcionalidad incompleta

¿Cómo puedes integrar código de una feature incompleta sin afectar a usuarios? Feature flags. Son switches en el código que controlan qué funcionalidad está activa. Una feature puede estar técnicamente en producción, pero desactivada para usuarios normales. Solo cuando está completa y validada, activas el flag.

Esto permite integración continua sin comprometer la estabilidad de producción. Los desarrolladores pueden trabajar en features grandes durante días o semanas, integrando continuamente, sin que usuarios vean trabajo incompleto.


Trunk-Based Development vs. Git Flow: Entendiendo las diferencias

Antes de profundizar en cómo implementar Trunk-Based Development, es crucial entender en qué se diferencia de otros modelos, especialmente Git Flow, que ha sido el estándar de facto en muchas organizaciones.

Git Flow: La filosofía de las ramas especializadas

Git Flow, que vimos anteriormente en este blog, organiza el trabajo en ramas especializadas con roles específicos:

  • main: Código en producción
  • develop: Integración de features
  • feature/*: Desarrollo de nuevas funcionalidades
  • release/*: Preparación de releases
  • hotfix/*: Arreglos urgentes de producción

La filosofía detrás de Git Flow es separación y aislamiento. Cada tipo de trabajo tiene su espacio, y la integración ocurre en momentos específicos del ciclo de vida. Es estructurado, predecible, y familiar para equipos acostumbrados a procesos tradicionales.

Trunk-Based Development: La filosofía de la integración continua

Trunk-Based Development simplifica radicalmente:

  • main (o trunk): Una sola rama donde ocurre todo el trabajo
  • short-lived branches (opcional): Ramas que viven horas, no días
  • Feature flags: Control de funcionalidad en el código, no en ramas

La filosofía es integración y feedback inmediato. El código fluye constantemente hacia una sola rama, validado continuamente por automatización.

Comparación práctica: El mismo sprint en ambos modelos

Para entender las diferencias reales, sigamos el mismo sprint de dos semanas en ambos modelos.

Escenario: Un equipo de 6 desarrolladores necesita implementar tres features: un nuevo sistema de autenticación (3 devs), un dashboard de analytics (2 devs), y optimizaciones de rendimiento en el backend (1 dev).

Sprint con Git Flow

Día 1 - Setup:

  • Dev 1, 2, 3 crean feature/auth-system
  • Dev 4, 5 crean feature/analytics-dashboard
  • Dev 6 crea feature/backend-optimization

Todos parten de develop, que está en el estado del sprint anterior.

Días 2-8 - Desarrollo: Cada equipo trabaja aislado en su rama. Dev 1 hace cambios en la estructura de la base de datos. Dev 4 no lo sabe, y basa su trabajo en la estructura antigua. Dev 6 refactoriza un servicio que Dev 2 está usando, pero Dev 2 no lo ve porque están en ramas diferentes.

Día 9 - Primera integración: Dev 1 termina una parte del auth system y crea un PR hacia develop. El tech lead revisa, pide cambios. Dev 1 corrige y finalmente se mergea al día siguiente.

Día 10 - Conflictos emergen: Dev 4 intenta mergear el dashboard. Tiene conflictos con los cambios de base de datos de Dev 1. Pasa 3 horas entendiendo qué cambió y adaptando su código.

Día 11-12 - Cascada de integraciones: Dev 2 mergea su parte del auth. Dev 6 descubre que su refactor rompió algo del auth. Dev 5 tiene conflictos con los cambios de Dev 4. El tech lead está sobrecargado revisando PRs masivos.

Día 13 - Integración en develop: Todas las features están en develop, pero la aplicación tiene bugs de integración que nadie vio porque las piezas nunca se probaron juntas hasta ahora.

Día 14 - Crisis: QA encuentra problemas. Los devs hacen fixes apresurados directamente en develop. La demo del sprint se hace con bugs conocidos. Se crea una release branch con trabajo incompleto que se deberá completar en el próximo sprint.

El mismo sprint con Trunk-Based Development

Día 1 - Setup y planificación: Todo el equipo revisa las stories juntos. Identifican dependencias: el auth system afecta cómo se implementa el dashboard, y el backend optimization podría afectar ambos. Acuerdan interfaces y puntos de integración.

Días 2-8 - Desarrollo con integración continua:

Dev 1 (Auth System):

  • Día 2 mañana: Agrega nuevas tablas de DB, schema migrations. Commit al trunk. CI valida. ✓
  • Día 2 tarde: Implementa modelo de User con nuevos campos. Commit al trunk. ✓
  • Día 3: Implementa servicio de autenticación básico. Feature flag desactivado. Commit. ✓
  • Cada commit es pequeño, validado, y visible para todos.

Dev 4 (Analytics Dashboard):

  • Día 2: Descarga los cambios de DB de Dev 1 en la tarde. Ve los nuevos campos de User.
  • Día 3: Implementa UI del dashboard usando los nuevos campos. Feature flag off. Commit. ✓
  • Día 4: Dev 1 refactoriza el servicio de auth. Dev 4 lo descarga, adapta su dashboard en 20 minutos.

Dev 6 (Backend Optimization):

  • Día 2: Identifica el servicio a refactorizar. Avisa al equipo en Slack.
  • Día 3: Hace el refactor. Tests de integración fallan porque afecta el auth de Dev 2.
  • Día 3 tarde: Dev 2 y Dev 6 se juntan 30 minutos, alinean el cambio. Re-commit. ✓

Días 9-12 - Completando features: Las features siguen desarrollándose, pero ya están integradas. QA tiene acceso a un ambiente de staging actualizado cada hora con los últimos cambios del trunk. Encuentran un bug de integración entre auth y dashboard en el día 10. Dev 1 y Dev 4 lo arreglan juntos en el día 11.

Día 13 - Activación de features: Las features están completas y probadas (porque han estado integrándose desde el día 2). El equipo activa los feature flags en staging. QA hace una validación final. Todo funciona.

Día 14 - Demo y deploy: Se activan los feature flags en producción. El deploy es anticlimático porque el código ya estaba en producción, solo desactivado. La demo muestra features completas y estables. El sprint termina sin drama.

Diferencias clave en la experiencia del equipo

La diferencia no es solo técnica, es en cómo se siente el trabajo diario:

Para los desarrolladores:

Git Flow: Trabajan aislados, largo tiempo sin feedback. Estrés al final cuando integran. Sorpresas sobre lo que hicieron otros.

Trunk-Based: Feedback constante, ajustes pequeños continuos. Saben siempre en qué están trabajando los demás. Menos sorpresas.

Para el tech lead:

Git Flow: Sobrecarga al final del sprint revisando PRs gigantes. Difícil dar feedback útil en cambios masivos. Resolviendo conflictos entre ramas.

Trunk-Based: Revisa PRs pequeños constantemente (si los usa). Feedback específico y útil. Menos tiempo en conflictos, más en arquitectura.

Para QA:

Git Flow: Recibe todo al final. Encuentra problemas cuando es caro arreglarlos. Presión al final del sprint.

Trunk-Based: Puede probar continuamente. Encuentra problemas temprano. Participa durante todo el sprint, no solo al final.

Para el equipo completo:

Git Flow: El sprint tiene fases: desarrollo aislado, luego integración caótica, luego estabilización apresurada.

Trunk-Based: El sprint es uniforme: integración continua, descubrimiento temprano de problemas, progreso visible constante.


Implementación práctica: De la teoría al código

Ahora que entiendes la filosofía, veamos cómo se implementa realmente Trunk-Based Development en un equipo ágil con el stack tecnológico descrito: backend en Java/Go, frontend en React, mobile en Flutter, usando GitHub para control de versiones y Azure Container Apps para deployment.

Configuración del repositorio: Estableciendo las bases

Lo primero que necesitas es configurar tu repositorio de manera que soporte el flujo de trabajo de Trunk-Based Development. Esto no es solo crear una rama llamada main, es establecer las reglas, protecciones, y automatización que harán que el modelo funcione.

Estructura del monorepo vs. múltiples repositorios

Para un equipo con backend, frontend, y mobile, tienes dos opciones principales:

Opción 1: Monorepo

Un solo repositorio que contiene todos los proyectos:

proyecto/
├── backend-java/
│   ├── src/
│   ├── pom.xml
│   └── ...
├── backend-go/
│   ├── cmd/
│   ├── internal/
│   ├── go.mod
│   └── ...
├── frontend-react/
│   ├── src/
│   ├── package.json
│   └── ...
├── mobile-flutter/
│   ├── lib/
│   ├── pubspec.yaml
│   └── ...
├── shared/
│   ├── contracts/
│   └── types/
└── .github/
    └── workflows/

Ventajas del monorepo para Trunk-Based Development:

  • Un solo trunk para todo el equipo
  • Visibilidad completa de cambios cross-platform
  • Refactors atómicos que afectan múltiples proyectos
  • Sincronización natural de versiones

Desventajas:

  • CI/CD más complejo (necesitas detectar qué cambió)
  • Repositorio grande puede ser lento
  • Permisos menos granulares

Opción 2: Repositorios separados

Cada proyecto en su propio repositorio:

backend-java-api/
backend-go-service/
frontend-web/
mobile-app/

Ventajas:

  • CI/CD más simple (cada repo su pipeline)
  • Equipos pueden tener autonomía completa
  • Repositorios más ligeros

Desventajas:

  • Sincronización de versiones más compleja
  • Cambios cross-platform requieren múltiples PRs
  • Menos visibilidad del trabajo completo

Recomendación para equipos ágiles:

Si tu equipo de 6 desarrolladores trabaja en las mismas features que tocan múltiples capas (frontend, backend, mobile), un monorepo funciona mejor con Trunk-Based Development. La sincronización natural y la visibilidad completa superan las desventajas. Para este artículo, asumiremos un monorepo.

Protección de la rama principal

Configurar protecciones en GitHub para main:

# Configuración en GitHub Settings -> Branches -> Branch protection rules

Branch name pattern: main

Protections:
  ✓ Require a pull request before merging
    ✓ Require approvals: 1
    ✓ Dismiss stale pull request approvals when new commits are pushed
    ✗ Require review from Code Owners (opcional, puede ser bottleneck)

  ✓ Require status checks to pass before merging
    ✓ Require branches to be up to date before merging
    Status checks required:
      - ci-backend-java
      - ci-backend-go
      - ci-frontend
      - ci-mobile
      - security-scan
      - lint-check

  ✓ Require conversation resolution before merging

  ✗ Require signed commits (opcional, para alta seguridad)

  ✓ Require linear history

  ✓ Include administrators (nadie saltea las reglas)

  ✗ Allow force pushes (nunca en trunk)

  ✗ Allow deletions (nunca borrar main)

La configuración crítica aquí es Require branches to be up to date before merging. Esto previene que un PR se mergee si el trunk avanzó desde que se creó la rama. Esto es fundamental para Trunk-Based Development porque asegura que cada merge fue probado contra el estado actual del trunk.

Configuración de Git hooks locales

Para prevenir problemas antes de que lleguen al servidor, configura hooks locales que todos los desarrolladores deben tener:

pre-commit hook (valida antes de cada commit):

#!/bin/bash
# .git/hooks/pre-commit

echo "🔍 Running pre-commit checks..."

# Detectar qué proyectos cambiaron
CHANGED_FILES=$(git diff --cached --name-only)

# Backend Java
if echo "$CHANGED_FILES" | grep -q "^backend-java/"; then
  echo "📦 Java changes detected, running checks..."
  cd backend-java
  mvn checkstyle:check || exit 1
  mvn test -Dtest='*UnitTest' || exit 1
  cd ..
fi

# Backend Go
if echo "$CHANGED_FILES" | grep -q "^backend-go/"; then
  echo "🐹 Go changes detected, running checks..."
  cd backend-go
  go fmt ./...
  go vet ./... || exit 1
  go test -short ./... || exit 1
  cd ..
fi

# Frontend React
if echo "$CHANGED_FILES" | grep -q "^frontend-react/"; then
  echo "⚛️  React changes detected, running checks..."
  cd frontend-react
  npm run lint || exit 1
  npm run type-check || exit 1
  npm run test:unit || exit 1
  cd ..
fi

# Mobile Flutter
if echo "$CHANGED_FILES" | grep -q "^mobile-flutter/"; then
  echo "📱 Flutter changes detected, running checks..."
  cd mobile-flutter
  flutter analyze || exit 1
  flutter test --coverage || exit 1
  cd ..
fi

echo "✅ All pre-commit checks passed!"

pre-push hook (valida antes de push):

#!/bin/bash
# .git/hooks/pre-push

echo "🚀 Running pre-push checks..."

# Asegurar que estamos actualizados con origin/main
git fetch origin main

# Verificar que no hay divergencia grande
COMMITS_BEHIND=$(git rev-list HEAD..origin/main --count)

if [ "$COMMITS_BEHIND" -gt 5 ]; then
  echo "⚠️  You are $COMMITS_BEHIND commits behind origin/main"
  echo "Please pull and rebase before pushing: git pull --rebase origin main"
  exit 1
fi

echo "✅ Pre-push checks passed!"

Conventional Commits enforcement

Para mantener un historial limpio y útil, fuerza Conventional Commits con commitlint:

# Instalar en la raíz del proyecto
npm install --save-dev @commitlint/cli @commitlint/config-conventional husky

# Configuración en commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [2, 'always', [
      'feat',     // Nueva funcionalidad
      'fix',      // Corrección de bug
      'docs',     // Cambios en documentación
      'style',    // Formateo, sin cambios de código
      'refactor', // Refactorización
      'perf',     // Mejoras de rendimiento
      'test',     // Agregar o modificar tests
      'chore',    // Tareas de mantenimiento
      'ci',       // Cambios en CI/CD
      'build',    // Cambios en sistema de build
      'revert'    // Revertir commit anterior
    ]],
    'scope-enum': [2, 'always', [
      'backend-java',
      'backend-go',
      'frontend',
      'mobile',
      'shared',
      'ci',
      'deps'
    ]],
    'subject-case': [2, 'always', 'lower-case'],
    'subject-max-length': [2, 'always', 100],
    'body-max-line-length': [2, 'always', 200]
  }
};

Ejemplos de commits válidos:

feat(backend-java): add JWT authentication endpoint
fix(mobile): resolve crash on profile image upload
refactor(shared): extract common validation logic to shared module
perf(backend-go): optimize database queries with connection pooling
docs(frontend): update component documentation with usage examples

El flujo de trabajo diario: Paso a paso

Ahora veamos cómo trabaja un desarrollador día a día en Trunk-Based Development.

Inicio del día: Sincronización

Cada mañana, antes de empezar a trabajar:

# 1. Asegurar que estás en main
git checkout main

# 2. Descargar los últimos cambios
git fetch origin

# 3. Actualizar tu main local con rebase (mantiene historial lineal)
git pull --rebase origin main

# 4. Si hay conflictos (raro si hiciste push el día anterior)
# Git te detendrá y pedirá que los resuelvas
# Resuelve archivo por archivo, luego:
git add <archivos-resueltos>
git rebase --continue

# 5. Verificar que todo compila y tests pasan
# Esto varía según el proyecto en el que trabajes
cd backend-java && mvn test && cd ..
# o
cd frontend-react && npm test && cd ..

Este ritual de sincronización es crítico. Te asegura que comienzas el día con la versión más reciente del código, incluyendo el trabajo de todos tus compañeros del día anterior.

Trabajando en una user story: El enfoque incremental

Supongamos que tienes una user story: “Como usuario, quiero poder cambiar mi foto de perfil para personalizar mi cuenta”.

Esta story involucra:

  • Backend Java: endpoint para subir imagen
  • Backend Go: servicio de procesamiento de imagen (resize, optimización)
  • Frontend React: UI para seleccionar y subir foto
  • Mobile Flutter: misma funcionalidad en app móvil

En Git Flow tradicional, crearías una rama feature/user-profile-picture y trabajarías ahí durante días. En Trunk-Based Development, divides el trabajo en pasos pequeños que puedes integrar continuamente.

Día 1 - Preparación de infraestructura:

Paso 1: Agregar esquema de base de datos para almacenar URL de imagen de perfil.

# Crear rama de vida corta (opcional, algunos equipos commitean directo a main)
git checkout -b profile-picture-db-schema

# Hacer el cambio
# backend-java/src/main/resources/db/migration/V023__add_profile_picture.sql

CREATE TABLE user_profile_pictures (
    user_id BIGINT PRIMARY KEY REFERENCES users(id),
    picture_url VARCHAR(500) NOT NULL,
    uploaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT fk_user_profile_pictures_user FOREIGN KEY (user_id)
        REFERENCES users(id) ON DELETE CASCADE
);

# Commit con mensaje convencional
git add backend-java/src/main/resources/db/migration/
git commit -m "feat(backend-java): add database schema for user profile pictures

- Add user_profile_pictures table
- Store picture URL and upload timestamp
- Foreign key relationship with users table

Part of: USER-123 (Jira ticket)"

# Push a rama de vida corta
git push origin profile-picture-db-schema

# Crear Pull Request inmediatamente (no esperar a que feature esté completa)
# El PR es pequeño: solo la migración de DB
# Review toma 10 minutos, se aprueba
# Merge al trunk el mismo día

Nota importante: Este cambio no afecta a nadie. Agrega una tabla que nadie usa todavía. Es completamente seguro integrar al trunk.

Paso 2: Agregar modelo de dominio en Java.

# Sincronizar primero (tu cambio de DB ya está en trunk)
git checkout main
git pull --rebase origin main

# Nueva rama de vida corta
git checkout -b profile-picture-model

# Crear el modelo
# backend-java/src/main/java/com/empresa/model/UserProfilePicture.java

@Entity
@Table(name = "user_profile_pictures")
public class UserProfilePicture {
    @Id
    @Column(name = "user_id")
    private Long userId;

    @Column(name = "picture_url", nullable = false)
    private String pictureUrl;

    @Column(name = "uploaded_at", nullable = false)
    private LocalDateTime uploadedAt;

    // Constructor, getters, setters...
}

# También agregar repository
# backend-java/src/main/java/com/empresa/repository/UserProfilePictureRepository.java

@Repository
public interface UserProfilePictureRepository
    extends JpaRepository<UserProfilePicture, Long> {
    Optional<UserProfilePicture> findByUserId(Long userId);
}

# Commit
git add backend-java/src/main/java/com/empresa/model/
git add backend-java/src/main/java/com/empresa/repository/
git commit -m "feat(backend-java): add UserProfilePicture domain model and repository

- Define entity matching database schema
- Add repository interface with findByUserId method
- No business logic yet, just data access layer

Part of: USER-123"

# Push y PR
git push origin profile-picture-model
# Crear PR, review rápido, merge

Nuevamente, este cambio es seguro. Agrega código que nadie llama todavía.

Día 2 - Backend Java: Endpoint de upload:

# Sincronizar
git checkout main
git pull --rebase origin main

# Nueva rama
git checkout -b profile-picture-upload-endpoint

# Implementar el servicio
# backend-java/src/main/java/com/empresa/service/ProfilePictureService.java

@Service
public class ProfilePictureService {
    private final UserProfilePictureRepository repository;
    private final ImageStorageService storageService; // Ya existe
    private final ImageProcessingClient processingClient; // Llamará al servicio Go

    @Autowired
    public ProfilePictureService(
        UserProfilePictureRepository repository,
        ImageStorageService storageService,
        ImageProcessingClient processingClient
    ) {
        this.repository = repository;
        this.storageService = storageService;
        this.processingClient = processingClient;
    }

    public String uploadProfilePicture(Long userId, MultipartFile file) {
        // 1. Validar archivo
        validateImageFile(file);

        // 2. Subir a storage temporal
        String tempUrl = storageService.uploadTemp(file);

        // 3. Enviar a servicio Go para procesamiento
        String processedUrl = processingClient.processImage(tempUrl);

        // 4. Guardar en DB
        UserProfilePicture profilePicture = new UserProfilePicture();
        profilePicture.setUserId(userId);
        profilePicture.setPictureUrl(processedUrl);
        profilePicture.setUploadedAt(LocalDateTime.now());
        repository.save(profilePicture);

        return processedUrl;
    }

    private void validateImageFile(MultipartFile file) {
        // Validaciones...
    }
}

# Implementar el controller
# backend-java/src/main/java/com/empresa/controller/ProfilePictureController.java

@RestController
@RequestMapping("/api/v1/profile")
public class ProfilePictureController {
    private final ProfilePictureService service;

    @Autowired
    public ProfilePictureController(ProfilePictureService service) {
        this.service = service;
    }

    @PostMapping("/picture")
    @FeatureFlag("profile-picture-upload") // Feature flag!
    public ResponseEntity<ProfilePictureResponse> uploadPicture(
        @RequestHeader("Authorization") String authHeader,
        @RequestParam("file") MultipartFile file
    ) {
        Long userId = extractUserIdFromToken(authHeader);
        String pictureUrl = service.uploadProfilePicture(userId, file);
        return ResponseEntity.ok(new ProfilePictureResponse(pictureUrl));
    }
}

# Tests unitarios
# backend-java/src/test/java/com/empresa/service/ProfilePictureServiceTest.java

@ExtendWith(MockitoExtension.class)
class ProfilePictureServiceTest {
    @Mock
    private UserProfilePictureRepository repository;

    @Mock
    private ImageStorageService storageService;

    @Mock
    private ImageProcessingClient processingClient;

    @InjectMocks
    private ProfilePictureService service;

    @Test
    void shouldUploadProfilePictureSuccessfully() {
        // Arrange
        Long userId = 1L;
        MultipartFile file = createMockImageFile();
        when(storageService.uploadTemp(file)).thenReturn("temp-url");
        when(processingClient.processImage("temp-url")).thenReturn("final-url");

        // Act
        String result = service.uploadProfilePicture(userId, file);

        // Assert
        assertEquals("final-url", result);
        verify(repository).save(any(UserProfilePicture.class));
    }

    // Más tests...
}

# Commit
git add backend-java/src/main/java/com/empresa/service/
git add backend-java/src/main/java/com/empresa/controller/
git add backend-java/src/test/java/com/empresa/service/
git commit -m "feat(backend-java): implement profile picture upload endpoint

- Add ProfilePictureService with upload logic
- Add REST endpoint POST /api/v1/profile/picture
- Protected by feature flag 'profile-picture-upload'
- Include unit tests with 85% coverage
- Integrates with existing storage service
- Calls Go service for image processing (to be implemented)

Part of: USER-123"

# Push y PR
git push origin profile-picture-upload-endpoint
# PR, review, merge

Nota el feature flag @FeatureFlag("profile-picture-upload"). Este endpoint está en el trunk, pero no está activo. El flag está desactivado por defecto. Esto permite integrar código sin exponerlo a usuarios.

Día 2 tarde - Backend Go: Servicio de procesamiento:

Mientras el primer desarrollador trabaja en Java, otro puede trabajar en paralelo en el servicio Go:

# Sincronizar
git checkout main
git pull --rebase origin main

# Nueva rama
git checkout -b image-processing-service

# Implementar el servicio
# backend-go/internal/imageprocessing/service.go

package imageprocessing

import (
    "context"
    "fmt"
    "image"
    _ "image/jpeg"
    _ "image/png"
    "github.com/disintegration/imaging"
)

type Service struct {
    storage StorageClient
    config  Config
}

type Config struct {
    MaxWidth  int
    MaxHeight int
    Quality   int
}

func NewService(storage StorageClient, config Config) *Service {
    return &Service{
        storage: storage,
        config:  config,
    }
}

func (s *Service) ProcessImage(ctx context.Context, imageURL string) (string, error) {
    // 1. Download image from temporary storage
    img, err := s.storage.Download(ctx, imageURL)
    if err != nil {
        return "", fmt.Errorf("failed to download image: %w", err)
    }

    // 2. Resize to max dimensions while maintaining aspect ratio
    resized := imaging.Fit(img, s.config.MaxWidth, s.config.MaxHeight, imaging.Lanczos)

    // 3. Optimize and upload to permanent storage
    finalURL, err := s.storage.UploadOptimized(ctx, resized, s.config.Quality)
    if err != nil {
        return "", fmt.Errorf("failed to upload processed image: %w", err)
    }

    // 4. Delete temporary file
    _ = s.storage.Delete(ctx, imageURL)

    return finalURL, nil
}

# Implementar el handler HTTP
# backend-go/internal/api/handlers/imageprocessing.go

package handlers

import (
    "encoding/json"
    "net/http"
    "github.com/empresa/backend-go/internal/imageprocessing"
)

type ImageProcessingHandler struct {
    service *imageprocessing.Service
}

func NewImageProcessingHandler(service *imageprocessing.Service) *ImageProcessingHandler {
    return &ImageProcessingHandler{service: service}
}

type ProcessImageRequest struct {
    ImageURL string `json:"image_url"`
}

type ProcessImageResponse struct {
    ProcessedURL string `json:"processed_url"`
}

func (h *ImageProcessingHandler) ProcessImage(w http.ResponseWriter, r *http.Request) {
    var req ProcessImageRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    processedURL, err := h.service.ProcessImage(r.Context(), req.ImageURL)
    if err != nil {
        http.Error(w, "processing failed", http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(ProcessImageResponse{ProcessedURL: processedURL})
}

# Tests
# backend-go/internal/imageprocessing/service_test.go

package imageprocessing_test

import (
    "context"
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func TestService_ProcessImage(t *testing.T) {
    // Arrange
    mockStorage := new(MockStorageClient)
    config := imageprocessing.Config{
        MaxWidth:  800,
        MaxHeight: 800,
        Quality:   85,
    }
    service := imageprocessing.NewService(mockStorage, config)

    mockStorage.On("Download", mock.Anything, "temp-url").Return(testImage, nil)
    mockStorage.On("UploadOptimized", mock.Anything, mock.Anything, 85).Return("final-url", nil)
    mockStorage.On("Delete", mock.Anything, "temp-url").Return(nil)

    // Act
    result, err := service.ProcessImage(context.Background(), "temp-url")

    // Assert
    assert.NoError(t, err)
    assert.Equal(t, "final-url", result)
    mockStorage.AssertExpectations(t)
}

# Commit
git add backend-go/internal/imageprocessing/
git add backend-go/internal/api/handlers/
git commit -m "feat(backend-go): implement image processing service

- Add image resizing with aspect ratio preservation
- Optimize images with configurable quality
- HTTP handler for processing requests
- Unit tests with mocked storage
- Max dimensions: 800x800px
- JPEG quality: 85%

Part of: USER-123"

# Push y PR
git push origin image-processing-service

Día 3 - Frontend React: UI sin funcionalidad:

# Sincronizar (ahora tanto Java como Go están en trunk)
git checkout main
git pull --rebase origin main

# Nueva rama
git checkout -b profile-picture-ui

# Crear componente UI
# frontend-react/src/components/ProfilePictureUpload/ProfilePictureUpload.tsx

import React, { useState } from 'react';
import { useFeatureFlag } from '@/hooks/useFeatureFlag';

interface ProfilePictureUploadProps {
  currentPictureUrl?: string;
  onUploadSuccess?: (newUrl: string) => void;
}

export const ProfilePictureUpload: React.FC<ProfilePictureUploadProps> = ({
  currentPictureUrl,
  onUploadSuccess
}) => {
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [preview, setPreview] = useState<string | null>(null);
  const [uploading, setUploading] = useState(false);

  // Feature flag check
  const isEnabled = useFeatureFlag('profile-picture-upload');

  if (!isEnabled) {
    return null; // No renderizar si feature está desactivada
  }

  const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) return;

    // Validar tipo de archivo
    if (!file.type.startsWith('image/')) {
      alert('Please select an image file');
      return;
    }

    // Validar tamaño (max 5MB)
    if (file.size > 5 * 1024 * 1024) {
      alert('Image must be less than 5MB');
      return;
    }

    setSelectedFile(file);

    // Generar preview
    const reader = new FileReader();
    reader.onloadend = () => {
      setPreview(reader.result as string);
    };
    reader.readAsDataURL(file);
  };

  const handleUpload = async () => {
    if (!selectedFile) return;

    setUploading(true);
    try {
      // TODO: Implementar llamada a API cuando backend esté listo
      // Por ahora solo simulamos
      await new Promise(resolve => setTimeout(resolve, 1000));

      // Simular éxito
      if (onUploadSuccess) {
        onUploadSuccess(preview!);
      }
    } catch (error) {
      console.error('Upload failed:', error);
      alert('Failed to upload image');
    } finally {
      setUploading(false);
    }
  };

  return (
    <div className="profile-picture-upload">
      <div className="current-picture">
        <img
          src={preview || currentPictureUrl || '/default-avatar.png'}
          alt="Profile"
          className="profile-picture-preview"
        />
      </div>

      <div className="upload-controls">
        <input
          type="file"
          accept="image/*"
          onChange={handleFileSelect}
          disabled={uploading}
          className="file-input"
        />

        {selectedFile && (
          <button
            onClick={handleUpload}
            disabled={uploading}
            className="upload-button"
          >
            {uploading ? 'Uploading...' : 'Upload Picture'}
          </button>
        )}
      </div>
    </div>
  );
};

# Tests del componente
# frontend-react/src/components/ProfilePictureUpload/ProfilePictureUpload.test.tsx

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ProfilePictureUpload } from './ProfilePictureUpload';

jest.mock('@/hooks/useFeatureFlag', () => ({
  useFeatureFlag: jest.fn(() => true) // Feature activa en tests
}));

describe('ProfilePictureUpload', () => {
  it('should render upload controls when feature is enabled', () => {
    render(<ProfilePictureUpload />);
    expect(screen.getByText(/upload/i)).toBeInTheDocument();
  });

  it('should show preview when file is selected', async () => {
    render(<ProfilePictureUpload />);

    const file = new File(['image'], 'test.png', { type: 'image/png' });
    const input = screen.getByRole('input', { type: 'file' });

    fireEvent.change(input, { target: { files: [file] } });

    await waitFor(() => {
      expect(screen.getByAltText('Profile')).toHaveAttribute('src');
    });
  });

  it('should validate file size', () => {
    render(<ProfilePictureUpload />);

    const largeFile = new File(['x'.repeat(6 * 1024 * 1024)], 'large.png', {
      type: 'image/png'
    });
    const input = screen.getByRole('input', { type: 'file' });

    fireEvent.change(input, { target: { files: [largeFile] } });

    // Debería mostrar alerta de tamaño
    expect(window.alert).toHaveBeenCalledWith('Image must be less than 5MB');
  });
});

# Commit
git add frontend-react/src/components/ProfilePictureUpload/
git commit -m "feat(frontend): add profile picture upload UI component

- Add ProfilePictureUpload component with file selection
- Show image preview before upload
- Validate file type (images only) and size (max 5MB)
- Protected by feature flag 'profile-picture-upload'
- Upload function stubbed (TODO: integrate with API)
- Unit tests with 90% coverage

Part of: USER-123"

# Push y PR
git push origin profile-picture-ui

Nota que el frontend ya está parcialmente funcional (puede seleccionar archivos, mostrar preview) pero aún no llama al backend. Esto es intencional: puedes integrar UI temprano y conectarla después.

Día 4 - Integración Frontend-Backend:

# Sincronizar (todo lo anterior está en trunk)
git checkout main
git pull --rebase origin main

# Nueva rama
git checkout -b integrate-profile-picture-api

# Crear cliente API
# frontend-react/src/api/profilePictureApi.ts

import axios from 'axios';

const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8080';

export interface UploadProfilePictureResponse {
  picture_url: string;
}

export const profilePictureApi = {
  async uploadPicture(file: File): Promise<string> {
    const formData = new FormData();
    formData.append('file', file);

    const response = await axios.post<UploadProfilePictureResponse>(
      `${API_BASE_URL}/api/v1/profile/picture`,
      formData,
      {
        headers: {
          'Content-Type': 'multipart/form-data',
          'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
        }
      }
    );

    return response.data.picture_url;
  },

  async getCurrentPicture(userId: number): Promise<string | null> {
    try {
      const response = await axios.get<{ picture_url: string }>(
        `${API_BASE_URL}/api/v1/profile/${userId}/picture`,
        {
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
          }
        }
      );
      return response.data.picture_url;
    } catch (error) {
      if (axios.isAxiosError(error) && error.response?.status === 404) {
        return null; // Usuario no tiene foto de perfil
      }
      throw error;
    }
  }
};

# Actualizar componente para usar API real
# frontend-react/src/components/ProfilePictureUpload/ProfilePictureUpload.tsx

// ... imports anteriores
import { profilePictureApi } from '@/api/profilePictureApi';

// ... props y estado anterior

const handleUpload = async () => {
  if (!selectedFile) return;

  setUploading(true);
  try {
    // Llamada real a API
    const newUrl = await profilePictureApi.uploadPicture(selectedFile);

    if (onUploadSuccess) {
      onUploadSuccess(newUrl);
    }

    // Limpiar estado
    setSelectedFile(null);
    setPreview(null);
  } catch (error) {
    console.error('Upload failed:', error);
    alert('Failed to upload image. Please try again.');
  } finally {
    setUploading(false);
  }
};

// ... resto del componente

# Commit
git add frontend-react/src/api/profilePictureApi.ts
git add frontend-react/src/components/ProfilePictureUpload/ProfilePictureUpload.tsx
git commit -m "feat(frontend): integrate profile picture upload with backend API

- Add profilePictureApi client for upload and fetch operations
- Update ProfilePictureUpload component to use real API
- Handle authentication with JWT token
- Error handling for network failures
- Remove TODO stub, feature now fully functional

Part of: USER-123"

# Push y PR
git push origin integrate-profile-picture-api

Día 5 - Mobile Flutter: Implementación completa:

El desarrollador de mobile puede trabajar basándose en lo que ya está en trunk:

// mobile-flutter/lib/features/profile/data/profile_picture_repository.dart

import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as path;

class ProfilePictureRepository {
  final String baseUrl;
  final http.Client httpClient;

  ProfilePictureRepository({
    required this.baseUrl,
    required this.httpClient,
  });

  Future<String> uploadProfilePicture(File imageFile, String authToken) async {
    final request = http.MultipartRequest(
      'POST',
      Uri.parse('$baseUrl/api/v1/profile/picture'),
    );

    request.headers['Authorization'] = 'Bearer $authToken';
    request.files.add(
      await http.MultipartFile.fromPath(
        'file',
        imageFile.path,
        filename: path.basename(imageFile.path),
      ),
    );

    final streamedResponse = await request.send();
    final response = await http.Response.fromStream(streamedResponse);

    if (response.statusCode == 200) {
      final jsonResponse = json.decode(response.body);
      return jsonResponse['picture_url'] as String;
    } else {
      throw Exception('Failed to upload profile picture');
    }
  }

  Future<String?> getCurrentProfilePicture(int userId, String authToken) async {
    final response = await httpClient.get(
      Uri.parse('$baseUrl/api/v1/profile/$userId/picture'),
      headers: {'Authorization': 'Bearer $authToken'},
    );

    if (response.statusCode == 200) {
      final jsonResponse = json.decode(response.body);
      return jsonResponse['picture_url'] as String;
    } else if (response.statusCode == 404) {
      return null; // Sin foto de perfil
    } else {
      throw Exception('Failed to fetch profile picture');
    }
  }
}

// mobile-flutter/lib/features/profile/presentation/widgets/profile_picture_upload_widget.dart

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';

class ProfilePictureUploadWidget extends StatefulWidget {
  final String? currentPictureUrl;
  final Function(String) onUploadSuccess;

  const ProfilePictureUploadWidget({
    Key? key,
    this.currentPictureUrl,
    required this.onUploadSuccess,
  }) : super(key: key);

  @override
  State<ProfilePictureUploadWidget> createState() =>
      _ProfilePictureUploadWidgetState();
}

class _ProfilePictureUploadWidgetState
    extends State<ProfilePictureUploadWidget> {
  File? _selectedImage;
  bool _uploading = false;
  final ImagePicker _picker = ImagePicker();

  Future<void> _selectImage() async {
    final XFile? image = await _picker.pickImage(
      source: ImageSource.gallery,
      maxWidth: 1024,
      maxHeight: 1024,
      imageQuality: 85,
    );

    if (image != null) {
      setState(() {
        _selectedImage = File(image.path);
      });
    }
  }

  Future<void> _uploadImage() async {
    if (_selectedImage == null) return;

    setState(() {
      _uploading = true;
    });

    try {
      // Obtener dependencies (en producción usarías Provider/Riverpod)
      final repository = context.read<ProfilePictureRepository>();
      final authToken = context.read<AuthState>().token;

      final newUrl = await repository.uploadProfilePicture(
        _selectedImage!,
        authToken,
      );

      widget.onUploadSuccess(newUrl);

      setState(() {
        _selectedImage = null;
      });

      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Profile picture updated!')),
      );
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Failed to upload: $e')),
      );
    } finally {
      setState(() {
        _uploading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        CircleAvatar(
          radius: 60,
          backgroundImage: _selectedImage != null
              ? FileImage(_selectedImage!)
              : widget.currentPictureUrl != null
                  ? NetworkImage(widget.currentPictureUrl!)
                  : const AssetImage('assets/default_avatar.png')
                      as ImageProvider,
        ),
        const SizedBox(height: 16),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton.icon(
              onPressed: _uploading ? null : _selectImage,
              icon: const Icon(Icons.photo_library),
              label: const Text('Select Photo'),
            ),
            if (_selectedImage != null) ...[
              const SizedBox(width: 16),
              ElevatedButton.icon(
                onPressed: _uploading ? null : _uploadImage,
                icon: _uploading
                    ? const SizedBox(
                        width: 16,
                        height: 16,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      )
                    : const Icon(Icons.upload),
                label: Text(_uploading ? 'Uploading...' : 'Upload'),
              ),
            ],
          ],
        ),
      ],
    );
  }
}

// Tests
// mobile-flutter/test/features/profile/data/profile_picture_repository_test.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:http/http.dart' as http;

void main() {
  group('ProfilePictureRepository', () {
    late ProfilePictureRepository repository;
    late MockHttpClient mockHttpClient;

    setUp(() {
      mockHttpClient = MockHttpClient();
      repository = ProfilePictureRepository(
        baseUrl: 'http://localhost:8080',
        httpClient: mockHttpClient,
      );
    });

    test('should upload profile picture successfully', () async {
      // Arrange
      final imageFile = File('test_image.jpg');
      final authToken = 'test-token';

      when(mockHttpClient.send(any)).thenAnswer((_) async {
        return http.StreamedResponse(
          Stream.value([...utf8.encode('{"picture_url":"http://example.com/pic.jpg"}')]),
          200,
        );
      });

      // Act
      final result = await repository.uploadProfilePicture(imageFile, authToken);

      // Assert
      expect(result, 'http://example.com/pic.jpg');
    });

    // Más tests...
  });
}
# Commit
git add mobile-flutter/lib/features/profile/
git add mobile-flutter/test/features/profile/
git commit -m "feat(mobile): implement profile picture upload functionality

- Add ProfilePictureRepository for API communication
- Add ProfilePictureUploadWidget with image picker
- Handle image selection from gallery
- Upload progress indicator
- Error handling with user feedback
- Unit tests for repository layer
- Widget tests for UI components

Part of: USER-123"

# Push y PR
git push origin mobile-profile-picture-upload

El resultado: Feature completa en 5 días

Al final de estos 5 días:

  • La feature está completamente implementada en todas las plataformas
  • Cada parte fue integrada al trunk continuamente
  • No hubo “día de integración” caótico
  • QA pudo probar partes de la feature desde el día 2
  • El feature flag permite activarla cuando estén listos
  • El historial de Git es limpio y cuenta la historia completa

Cada commit fue pequeño, revisable, y testeable. Ningún desarrollador tuvo que lidiar con merges masivos. El equipo mantuvo visibilidad completa del progreso durante todo el sprint.


Feature Flags: El enabler crítico de Trunk-Based Development

Los feature flags (también llamados feature toggles) son absolutamente esenciales para Trunk-Based Development. Sin ellos, no podrías integrar código incompleto al trunk sin afectar a usuarios. Veamos cómo implementarlos profesionalmente.

Arquitectura de feature flags

Un sistema de feature flags necesita varias capas:

Capa 1: Almacenamiento de configuración

Los flags necesitan guardarse en algún lugar donde puedan actualizarse sin redesplegar código:

# config/feature-flags.yaml (para desarrollo local)

features:
  profile-picture-upload:
    enabled: false
    description: "Allow users to upload profile pictures"
    rollout_percentage: 0
    environments:
      development: true
      staging: true
      production: false

  new-analytics-dashboard:
    enabled: false
    description: "New analytics dashboard with real-time metrics"
    rollout_percentage: 0
    user_whitelist:
      - "admin@empresa.com"
      - "qa-team@empresa.com"

  performance-optimization-backend:
    enabled: true
    description: "Backend performance optimizations"
    rollout_percentage: 100

Para producción, usa un servicio dedicado como LaunchDarkly, Unleash, o implementa tu propio servicio:

// backend-java/src/main/java/com/empresa/featureflags/FeatureFlagService.java

@Service
public class FeatureFlagService {
    private final FeatureFlagRepository repository;
    private final ConcurrentHashMap<String, FeatureFlag> cache;

    @Autowired
    public FeatureFlagService(FeatureFlagRepository repository) {
        this.repository = repository;
        this.cache = new ConcurrentHashMap<>();
        initializeCache();
    }

    public boolean isEnabled(String flagName, UserContext userContext) {
        FeatureFlag flag = cache.get(flagName);
        if (flag == null) {
            log.warn("Feature flag '{}' not found, defaulting to disabled", flagName);
            return false;
        }

        // Verificar ambiente
        if (!flag.isEnabledForEnvironment(getCurrentEnvironment())) {
            return false;
        }

        // Verificar whitelist de usuarios
        if (!flag.getUserWhitelist().isEmpty()) {
            return flag.getUserWhitelist().contains(userContext.getEmail());
        }

        // Verificar rollout percentage
        if (flag.getRolloutPercentage() < 100) {
            return isUserInRolloutPercentage(
                userContext.getUserId(),
                flag.getRolloutPercentage()
            );
        }

        return flag.isEnabled();
    }

    private boolean isUserInRolloutPercentage(Long userId, int percentage) {
        // Consistente para el mismo usuario (no aleatorio en cada request)
        int hash = Math.abs(userId.hashCode() % 100);
        return hash < percentage;
    }

    @Scheduled(fixedRate = 30000) // Refrescar cada 30 segundos
    public void refreshCache() {
        List<FeatureFlag> flags = repository.findAll();
        flags.forEach(flag -> cache.put(flag.getName(), flag));
        log.debug("Feature flags cache refreshed: {} flags loaded", flags.size());
    }

    private void initializeCache() {
        refreshCache();
    }

    private String getCurrentEnvironment() {
        return System.getenv("ENVIRONMENT").orElse("development");
    }
}

Capa 2: Uso en código

Los feature flags deben ser fáciles de usar para los desarrolladores:

Backend Java:

// Usando anotación personalizada
@RestController
@RequestMapping("/api/v1/profile")
public class ProfileController {

    @GetMapping("/picture")
    @RequireFeatureFlag("profile-picture-upload")
    public ResponseEntity<ProfilePictureDto> getProfilePicture(
        @AuthenticationPrincipal UserDetails userDetails
    ) {
        // Implementación...
    }
}

// Implementación del aspecto
@Aspect
@Component
public class FeatureFlagAspect {

    @Autowired
    private FeatureFlagService featureFlagService;

    @Around("@annotation(requireFeatureFlag)")
    public Object checkFeatureFlag(
        ProceedingJoinPoint joinPoint,
        RequireFeatureFlag requireFeatureFlag
    ) throws Throwable {
        String flagName = requireFeatureFlag.value();
        UserContext userContext = extractUserContext();

        if (!featureFlagService.isEnabled(flagName, userContext)) {
            throw new FeatureNotEnabledException(
                "Feature '" + flagName + "' is not enabled"
            );
        }

        return joinPoint.proceed();
    }
}

// Uso programático cuando necesitas lógica condicional
@Service
public class ProfileService {

    @Autowired
    private FeatureFlagService featureFlagService;

    public ProfileDto getProfile(Long userId) {
        ProfileDto profile = repository.findById(userId);

        UserContext context = UserContext.fromUserId(userId);

        // Lógica condicional basada en flag
        if (featureFlagService.isEnabled("profile-picture-upload", context)) {
            profile.setPictureUploadEnabled(true);
            profile.setPictureUrl(getProfilePictureUrl(userId));
        } else {
            profile.setPictureUploadEnabled(false);
            profile.setPictureUrl(getDefaultAvatar());
        }

        return profile;
    }
}

Frontend React:

// frontend-react/src/hooks/useFeatureFlag.ts

import { createContext, useContext, useEffect, useState } from "react";

interface FeatureFlagContextType {
  isEnabled: (flagName: string) => boolean;
  flags: Record<string, boolean>;
  loading: boolean;
}

const FeatureFlagContext = createContext<FeatureFlagContextType>({
  isEnabled: () => false,
  flags: {},
  loading: true,
});

export const FeatureFlagProvider: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const [flags, setFlags] = useState<Record<string, boolean>>({});
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchFlags = async () => {
      try {
        const response = await fetch("/api/v1/feature-flags", {
          headers: {
            Authorization: `Bearer ${localStorage.getItem("auth_token")}`,
          },
        });
        const data = await response.json();
        setFlags(data);
      } catch (error) {
        console.error("Failed to fetch feature flags:", error);
        // En caso de error, todos los flags desactivados (fail-safe)
        setFlags({});
      } finally {
        setLoading(false);
      }
    };

    fetchFlags();

    // Refrescar cada 60 segundos
    const interval = setInterval(fetchFlags, 60000);
    return () => clearInterval(interval);
  }, []);

  const isEnabled = (flagName: string): boolean => {
    return flags[flagName] === true;
  };

  return (
    <FeatureFlagContext.Provider value={{ isEnabled, flags, loading }}>
      {children}
    </FeatureFlagContext.Provider>
  );
};

export const useFeatureFlag = (flagName: string): boolean => {
  const { isEnabled } = useContext(FeatureFlagContext);
  return isEnabled(flagName);
};

// Uso en componentes
import { useFeatureFlag } from "@/hooks/useFeatureFlag";

export const ProfilePage: React.FC = () => {
  const profilePictureEnabled = useFeatureFlag("profile-picture-upload");

  return (
    <div className="profile-page">
      <h1>My Profile</h1>

      {profilePictureEnabled && <ProfilePictureUpload />}

      {/* Resto del perfil siempre visible */}
      <ProfileInfo />
      <ProfileSettings />
    </div>
  );
};

Mobile Flutter:

// mobile-flutter/lib/core/feature_flags/feature_flag_service.dart

import 'dart:async';
import 'package:http/http.dart' as http;

class FeatureFlagService {
  final String baseUrl;
  final http.Client httpClient;
  final Duration refreshInterval;

  Map<String, bool> _flags = {};
  Timer? _refreshTimer;

  FeatureFlagService({
    required this.baseUrl,
    required this.httpClient,
    this.refreshInterval = const Duration(seconds: 60),
  });

  Future<void> initialize() async {
    await _fetchFlags();
    _startRefreshTimer();
  }

  bool isEnabled(String flagName) {
    return _flags[flagName] ?? false;
  }

  Future<void> _fetchFlags() async {
    try {
      final response = await httpClient.get(
        Uri.parse('$baseUrl/api/v1/feature-flags'),
        headers: {
          'Authorization': 'Bearer ${await _getAuthToken()}',
        },
      );

      if (response.statusCode == 200) {
        final data = json.decode(response.body) as Map<String, dynamic>;
        _flags = data.map((key, value) => MapEntry(key, value as bool));
      }
    } catch (e) {
      print('Failed to fetch feature flags: $e');
      // Mantener flags existentes en caso de error
    }
  }

  void _startRefreshTimer() {
    _refreshTimer?.cancel();
    _refreshTimer = Timer.periodic(refreshInterval, (_) => _fetchFlags());
  }

  void dispose() {
    _refreshTimer?.cancel();
  }
}

// Uso con Provider
class ProfilePage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final featureFlags = ref.watch(featureFlagServiceProvider);
    final profilePictureEnabled = featureFlags.isEnabled('profile-picture-upload');

    return Scaffold(
      appBar: AppBar(title: Text('My Profile')),
      body: Column(
        children: [
          if (profilePictureEnabled)
            ProfilePictureUploadWidget(),
          ProfileInfoWidget(),
          ProfileSettingsWidget(),
        ],
      ),
    );
  }
}

Estrategias de rollout con feature flags

Los feature flags permiten rollouts graduales y seguros:

Rollout por porcentaje:

// Activar para 10% de usuarios
UPDATE feature_flags
SET rollout_percentage = 10
WHERE name = 'profile-picture-upload';

// Incrementar gradualmente
-- Día 1: 10%
-- Día 2: 25% (sin problemas reportados)
-- Día 3: 50%
-- Día 4: 100% (rollout completo)

Rollout por ambiente:

# Staging primero
profile-picture-upload:
  environments:
    staging: true
    production: false

# Después de validación en staging
profile-picture-upload:
  environments:
    staging: true
    production: true

Rollout a usuarios específicos (beta testers):

profile-picture-upload:
  user_whitelist:
    - beta-tester-1@empresa.com
    - beta-tester-2@empresa.com
    - internal-team@empresa.com

Rollout por región geográfica:

@Service
public class FeatureFlagService {

    public boolean isEnabled(String flagName, UserContext userContext) {
        FeatureFlag flag = getFlag(flagName);

        // Verificar región
        if (!flag.getEnabledRegions().isEmpty()) {
            String userRegion = geoLocationService.getRegion(userContext.getIpAddress());
            if (!flag.getEnabledRegions().contains(userRegion)) {
                return false;
            }
        }

        return flag.isEnabled();
    }
}

Limpieza de feature flags

Los feature flags no son permanentes. Una vez que una feature está completamente rollout y estable, debes limpiar el flag:

# 1. Buscar todos los usos del flag en el código
git grep -n "profile-picture-upload"

# 2. Remover todas las verificaciones del flag
# 3. Dejar el código de la feature siempre activo
# 4. Eliminar el flag de la configuración

# Commit de limpieza
git commit -m "chore: remove profile-picture-upload feature flag

Feature has been stable in production for 2 weeks with 100% rollout.
Removing flag to simplify codebase.

- Remove flag checks from ProfileController
- Remove flag checks from ProfilePage component
- Remove flag from feature_flags table
- Update documentation"

Política recomendada: Revisar feature flags cada sprint y eliminar aquellos que llevan más de 2-4 semanas en 100% rollout sin problemas.


Pull Requests en Trunk-Based Development: Pequeños y frecuentes

Los Pull Requests (PRs) en Trunk-Based Development son fundamentalmente diferentes de los PRs tradicionales. No son “features completas”, son pasos incrementales.

Características de PRs en Trunk-Based Development

1. Tamaño pequeño

Un PR típico en Trunk-Based Development:

  • Toca 1-5 archivos
  • Agrega/modifica 50-300 líneas de código
  • Se puede revisar en 10-20 minutos
  • Tiene un propósito claro y único

2. Frecuencia alta

Un desarrollador en Trunk-Based Development:

  • Crea 2-5 PRs por día
  • No acumula trabajo en una rama por días
  • Integra constantemente

3. Vida corta

Un PR en Trunk-Based Development:

  • Se crea por la mañana, se mergea por la tarde
  • Vive menos de 24 horas (idealmente menos de 4 horas)
  • No espera a que una feature esté completa

Template de PR para Trunk-Based Development

# PR Template (.github/pull_request_template.md)

## Tipo de cambio

- [ ] feat: Nueva funcionalidad
- [ ] fix: Corrección de bug
- [ ] refactor: Refactorización sin cambio de funcionalidad
- [ ] perf: Mejora de rendimiento
- [ ] test: Agregar o modificar tests
- [ ] docs: Cambios en documentación
- [ ] chore: Tareas de mantenimiento

## Descripción

<!-- Describe brevemente qué hace este cambio -->

## Parte de

<!-- User story o ticket de Jira -->

Jira: [USER-123](https://jira.empresa.com/browse/USER-123)

## Contexto

<!-- ¿Por qué este cambio es necesario? -->

## Este PR

<!-- Qué hace específicamente este PR (no toda la feature) -->

- [ ] Agrega modelo X
- [ ] Implementa servicio Y
- [ ] Agrega tests unitarios

## Este PR NO incluye

<!-- Qué falta para completar la feature (si aplica) -->

- [ ] Integración con frontend (siguiente PR)
- [ ] Activación de feature flag (después de validación)

## Tests

- [ ] Tests unitarios agregados
- [ ] Tests de integración actualizados
- [ ] CI passing

## Feature flag

- [ ] Código protegido por feature flag: `nombre-del-flag`
- [ ] No requiere feature flag (no afecta usuarios)

## Checklist de review

- [ ] El código compila sin errores
- [ ] Los tests pasan localmente
- [ ] El código sigue las convenciones del equipo
- [ ] La documentación está actualizada (si aplica)
- [ ] No hay secretos o credenciales en el código
- [ ] El código es legible y está bien comentado donde es necesario

## Screenshots (si aplica)

<!-- Para cambios de UI -->

## Deployment notes

<!-- ¿Hay algo que el equipo de DevOps deba saber? -->

- [ ] No requiere cambios de infraestructura
- [ ] Requiere migración de base de datos (ver script en PR)
- [ ] Requiere actualización de variables de ambiente

Proceso de review de PRs

En Trunk-Based Development, las reviews deben ser rápidas porque los PRs son pequeños:

Review checklist (para reviewer):

## Quick Review Checklist (5-15 minutos)

### Funcionalidad

- [ ] El cambio hace lo que dice que hace
- [ ] No rompe funcionalidad existente
- [ ] Feature flag apropiado (si agrega funcionalidad visible)

### Código

- [ ] Código legible y bien estructurado
- [ ] Sin código duplicado innecesario
- [ ] Nombres de variables y funciones descriptivos
- [ ] Comentarios donde la lógica es compleja

### Tests

- [ ] Tests cubren los casos principales
- [ ] Tests son claros y mantenibles
- [ ] No hay tests flakey (que fallan aleatoriamente)

### Seguridad

- [ ] No hay credenciales o secretos hardcodeados
- [ ] Input validation donde sea necesario
- [ ] No hay vulnerabilidades obvias

### Integración

- [ ] CI passing (tests, lints, security scans)
- [ ] No hay conflictos con main
- [ ] Migración de DB es segura (si aplica)

## Resultado

- [ ] **Approve** - Ready to merge
- [ ] **Request changes** - Needs work
- [ ] **Comment** - Suggestions but not blocking

Tiempo objetivo de review: Máximo 4 horas desde que se crea el PR hasta que se mergea. PRs que esperan más de un día probablemente son demasiado grandes o complejos.

Automatización de PRs con GitHub Actions

Configura validación automatizada que corre en cada PR:

# .github/workflows/pr-validation.yml

name: PR Validation

on:
  pull_request:
    branches: [main]

jobs:
  validate-pr:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0 # Necesario para detectar cambios

      - name: Validate PR size
        run: |
          CHANGED_FILES=$(git diff --name-only origin/main...HEAD | wc -l)
          CHANGED_LINES=$(git diff --stat origin/main...HEAD | tail -1 | awk '{print $4+$6}')

          echo "Changed files: $CHANGED_FILES"
          echo "Changed lines: $CHANGED_LINES"

          if [ $CHANGED_FILES -gt 15 ]; then
            echo "::warning::PR touches $CHANGED_FILES files (recommended max: 15)"
            echo "Consider breaking this into smaller PRs"
          fi

          if [ $CHANGED_LINES -gt 500 ]; then
            echo "::error::PR changes $CHANGED_LINES lines (max: 500)"
            echo "Please break this into smaller, reviewable PRs"
            exit 1
          fi

      - name: Validate commit messages
        uses: wagoid/commitlint-github-action@v5
        with:
          configFile: commitlint.config.js

      - name: Detect changed services
        id: changes
        run: |
          echo "::set-output name=backend-java::$(git diff --name-only origin/main...HEAD | grep '^backend-java/' || echo '')"
          echo "::set-output name=backend-go::$(git diff --name-only origin/main...HEAD | grep '^backend-go/' || echo '')"
          echo "::set-output name=frontend::$(git diff --name-only origin/main...HEAD | grep '^frontend-react/' || echo '')"
          echo "::set-output name=mobile::$(git diff --name-only origin/main...HEAD | grep '^mobile-flutter/' || echo '')"

  test-backend-java:
    needs: validate-pr
    if: steps.changes.outputs.backend-java != ''
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: "17"
          distribution: "temurin"

      - name: Cache Maven packages
        uses: actions/cache@v3
        with:
          path: ~/.m2
          key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}

      - name: Run tests
        run: |
          cd backend-java
          mvn clean test

      - name: Check code coverage
        run: |
          cd backend-java
          mvn jacoco:report
          COVERAGE=$(grep -oP 'Total.*?([0-9.]+)%' target/site/jacoco/index.html | grep -oP '[0-9.]+')
          if (( $(echo "$COVERAGE < 70" | bc -l) )); then
            echo "::error::Code coverage is $COVERAGE% (minimum: 70%)"
            exit 1
          fi

      - name: Run linting
        run: |
          cd backend-java
          mvn checkstyle:check

  test-backend-go:
    needs: validate-pr
    if: steps.changes.outputs.backend-go != ''
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Set up Go
        uses: actions/setup-go@v4
        with:
          go-version: "1.21"

      - name: Cache Go modules
        uses: actions/cache@v3
        with:
          path: ~/go/pkg/mod
          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}

      - name: Run tests
        run: |
          cd backend-go
          go test -v -race -coverprofile=coverage.out ./...

      - name: Check code coverage
        run: |
          cd backend-go
          COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
          if (( $(echo "$COVERAGE < 70" | bc -l) )); then
            echo "::error::Code coverage is $COVERAGE% (minimum: 70%)"
            exit 1
          fi

      - name: Run linting
        run: |
          cd backend-go
          go vet ./...
          golangci-lint run

  test-frontend:
    needs: validate-pr
    if: steps.changes.outputs.frontend != ''
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: "18"

      - name: Cache node modules
        uses: actions/cache@v3
        with:
          path: frontend-react/node_modules
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}

      - name: Install dependencies
        run: |
          cd frontend-react
          npm ci

      - name: Run tests
        run: |
          cd frontend-react
          npm run test:coverage

      - name: Check code coverage
        run: |
          cd frontend-react
          COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
          if (( $(echo "$COVERAGE < 70" | bc -l) )); then
            echo "::error::Code coverage is $COVERAGE% (minimum: 70%)"
            exit 1
          fi

      - name: Run linting
        run: |
          cd frontend-react
          npm run lint
          npm run type-check

      - name: Build
        run: |
          cd frontend-react
          npm run build

  test-mobile:
    needs: validate-pr
    if: steps.changes.outputs.mobile != ''
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Set up Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: "3.16.0"

      - name: Get dependencies
        run: |
          cd mobile-flutter
          flutter pub get

      - name: Run tests
        run: |
          cd mobile-flutter
          flutter test --coverage

      - name: Check code coverage
        run: |
          cd mobile-flutter
          # Flutter coverage report...

      - name: Run analyzer
        run: |
          cd mobile-flutter
          flutter analyze

  security-scan:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: "fs"
          scan-ref: "."
          format: "sarif"
          output: "trivy-results.sarif"

      - name: Upload Trivy results to GitHub Security
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: "trivy-results.sarif"

  auto-merge:
    needs:
      [
        test-backend-java,
        test-backend-go,
        test-frontend,
        test-mobile,
        security-scan,
      ]
    if: always() && github.actor == 'dependabot[bot]'
    runs-on: ubuntu-latest

    steps:
      - name: Auto-merge Dependabot PRs
        uses: ahmadnassri/action-dependabot-auto-merge@v2
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          target: minor

Esta configuración asegura que cada PR:

  • No es demasiado grande
  • Pasa todos los tests
  • Mantiene coverage mínimo
  • Pasa linting y análisis estático
  • No tiene vulnerabilidades conocidas
  • Sigue convenciones de commits

Integración con CI/CD: Azure Container Apps y GitHub Actions

La automatización es crítica para Trunk-Based Development. Cada commit al trunk debe deployarse automáticamente a ambientes de desarrollo y staging.

Arquitectura de ambientes

Desarrolladores → GitHub (main branch)

                GitHub Actions CI/CD

         ┌───────────┴───────────┐
         ↓                       ↓
    Development              Staging
  (auto-deploy)          (auto-deploy)
 Azure Container          Azure Container
      Apps                     Apps
         ↓                       ↓
    QA validation         Final validation

                           Production
                        (manual trigger)
                       Azure Container
                             Apps

Ambientes:

  • Development: Deploy automático de cada commit a main. Para pruebas rápidas de desarrolladores.
  • Staging: Deploy automático después de pasar todos los tests. Réplica exacta de producción para QA.
  • Production: Deploy manual con aprobación. Solo después de validación completa en staging.

Pipeline de CI/CD completo

# .github/workflows/deploy.yml

name: Deploy to Environments

on:
  push:
    branches: [main]
  workflow_dispatch: # Permitir trigger manual

env:
  AZURE_CONTAINER_REGISTRY: empresa.azurecr.io
  RESOURCE_GROUP: empresa-prod-rg

jobs:
  build-and-test:
    name: Build and Test All Services
    runs-on: ubuntu-latest

    outputs:
      backend-java-image: ${{ steps.meta-java.outputs.tags }}
      backend-go-image: ${{ steps.meta-go.outputs.tags }}
      frontend-image: ${{ steps.meta-frontend.outputs.tags }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Get short SHA
        id: vars
        run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT

      # Backend Java
      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: "17"
          distribution: "temurin"

      - name: Build and test backend-java
        run: |
          cd backend-java
          mvn clean package -DskipTests=false
          mvn verify

      - name: Docker meta for backend-java
        id: meta-java
        uses: docker/metadata-action@v4
        with:
          images: ${{ env.AZURE_CONTAINER_REGISTRY }}/backend-java
          tags: |
            type=sha,prefix={{branch}}-
            type=raw,value=latest-{{branch}}

      - name: Build and push backend-java image
        uses: docker/build-push-action@v4
        with:
          context: ./backend-java
          push: true
          tags: ${{ steps.meta-java.outputs.tags }}
          cache-from: type=registry,ref=${{ env.AZURE_CONTAINER_REGISTRY }}/backend-java:buildcache
          cache-to: type=registry,ref=${{ env.AZURE_CONTAINER_REGISTRY }}/backend-java:buildcache,mode=max

      # Backend Go
      - name: Set up Go
        uses: actions/setup-go@v4
        with:
          go-version: "1.21"

      - name: Build and test backend-go
        run: |
          cd backend-go
          go test -v -race ./...
          go build -o app ./cmd/server

      - name: Docker meta for backend-go
        id: meta-go
        uses: docker/metadata-action@v4
        with:
          images: ${{ env.AZURE_CONTAINER_REGISTRY }}/backend-go
          tags: |
            type=sha,prefix={{branch}}-
            type=raw,value=latest-{{branch}}

      - name: Build and push backend-go image
        uses: docker/build-push-action@v4
        with:
          context: ./backend-go
          push: true
          tags: ${{ steps.meta-go.outputs.tags }}

      # Frontend React
      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: "18"

      - name: Build and test frontend
        run: |
          cd frontend-react
          npm ci
          npm run test:ci
          npm run build

      - name: Docker meta for frontend
        id: meta-frontend
        uses: docker/metadata-action@v4
        with:
          images: ${{ env.AZURE_CONTAINER_REGISTRY }}/frontend
          tags: |
            type=sha,prefix={{branch}}-
            type=raw,value=latest-{{branch}}

      - name: Build and push frontend image
        uses: docker/build-push-action@v4
        with:
          context: ./frontend-react
          push: true
          tags: ${{ steps.meta-frontend.outputs.tags }}

      # Scan de seguridad
      - name: Run Trivy security scan
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: "image"
          image-ref: ${{ steps.meta-java.outputs.tags }}
          format: "table"
          exit-code: "1"
          severity: "CRITICAL,HIGH"

  deploy-development:
    name: Deploy to Development
    needs: build-and-test
    runs-on: ubuntu-latest
    environment:
      name: development
      url: https://dev.empresa.com

    steps:
      - name: Azure Login
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS_DEV }}

      - name: Deploy backend-java to Azure Container Apps
        uses: azure/container-apps-deploy-action@v1
        with:
          resourceGroup: ${{ env.RESOURCE_GROUP }}-dev
          containerAppName: backend-java-dev
          imageToDeploy: ${{ needs.build-and-test.outputs.backend-java-image }}
          environmentVariables: |
            ENVIRONMENT=development
            DATABASE_URL=${{ secrets.DEV_DATABASE_URL }}
            FEATURE_FLAG_SERVICE_URL=https://flags-dev.empresa.com

      - name: Deploy backend-go to Azure Container Apps
        uses: azure/container-apps-deploy-action@v1
        with:
          resourceGroup: ${{ env.RESOURCE_GROUP }}-dev
          containerAppName: backend-go-dev
          imageToDeploy: ${{ needs.build-and-test.outputs.backend-go-image }}
          environmentVariables: |
            ENVIRONMENT=development
            STORAGE_ACCOUNT=${{ secrets.DEV_STORAGE_ACCOUNT }}

      - name: Deploy frontend to Azure Container Apps
        uses: azure/container-apps-deploy-action@v1
        with:
          resourceGroup: ${{ env.RESOURCE_GROUP }}-dev
          containerAppName: frontend-dev
          imageToDeploy: ${{ needs.build-and-test.outputs.frontend-image }}
          environmentVariables: |
            REACT_APP_API_URL=https://api-dev.empresa.com
            REACT_APP_ENVIRONMENT=development

      - name: Notify team on Slack
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "✅ Deployed to Development",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*Deployed to Development*\nCommit: ${{ github.sha }}\nAuthor: ${{ github.actor }}\nURL: https://dev.empresa.com"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_DEV }}

  deploy-staging:
    name: Deploy to Staging
    needs: deploy-development
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.empresa.com

    steps:
      - name: Azure Login
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS_STAGING }}

      - name: Deploy backend-java to Staging
        uses: azure/container-apps-deploy-action@v1
        with:
          resourceGroup: ${{ env.RESOURCE_GROUP }}-staging
          containerAppName: backend-java-staging
          imageToDeploy: ${{ needs.build-and-test.outputs.backend-java-image }}
          environmentVariables: |
            ENVIRONMENT=staging
            DATABASE_URL=${{ secrets.STAGING_DATABASE_URL }}
            FEATURE_FLAG_SERVICE_URL=https://flags-staging.empresa.com

      - name: Deploy backend-go to Staging
        uses: azure/container-apps-deploy-action@v1
        with:
          resourceGroup: ${{ env.RESOURCE_GROUP }}-staging
          containerAppName: backend-go-staging
          imageToDeploy: ${{ needs.build-and-test.outputs.backend-go-image }}
          environmentVariables: |
            ENVIRONMENT=staging
            STORAGE_ACCOUNT=${{ secrets.STAGING_STORAGE_ACCOUNT }}

      - name: Deploy frontend to Staging
        uses: azure/container-apps-deploy-action@v1
        with:
          resourceGroup: ${{ env.RESOURCE_GROUP }}-staging
          containerAppName: frontend-staging
          imageToDeploy: ${{ needs.build-and-test.outputs.frontend-image }}
          environmentVariables: |
            REACT_APP_API_URL=https://api-staging.empresa.com
            REACT_APP_ENVIRONMENT=staging

      - name: Run smoke tests
        run: |
          # Esperar a que los servicios estén listos
          sleep 30

          # Smoke tests básicos
          curl -f https://api-staging.empresa.com/health || exit 1
          curl -f https://staging.empresa.com || exit 1

      - name: Notify QA team
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "🧪 New build ready for testing in Staging",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*Ready for QA Testing*\n@qa-team Nueva build disponible en Staging\nCommit: ${{ github.sha }}\nAuthor: ${{ github.actor }}\nURL: https://staging.empresa.com\n\nChanges:\n${{ github.event.head_commit.message }}"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_QA }}

  deploy-production:
    name: Deploy to Production
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://empresa.com
    # Requiere aprobación manual

    steps:
      - name: Azure Login
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS_PROD }}

      - name: Create deployment tag
        run: |
          git tag -a "deploy-$(date +%Y%m%d-%H%M%S)" -m "Production deployment"
          git push origin --tags

      - name: Deploy backend-java to Production
        uses: azure/container-apps-deploy-action@v1
        with:
          resourceGroup: ${{ env.RESOURCE_GROUP }}-prod
          containerAppName: backend-java-prod
          imageToDeploy: ${{ needs.build-and-test.outputs.backend-java-image }}
          environmentVariables: |
            ENVIRONMENT=production
            DATABASE_URL=${{ secrets.PROD_DATABASE_URL }}
            FEATURE_FLAG_SERVICE_URL=https://flags.empresa.com

      - name: Health check after deployment
        run: |
          # Esperar y verificar health
          sleep 60

          for i in {1..10}; do
            if curl -f https://api.empresa.com/health; then
              echo "Health check passed"
              exit 0
            fi
            echo "Attempt $i failed, retrying..."
            sleep 10
          done

          echo "Health check failed"
          exit 1

      - name: Rollback on failure
        if: failure()
        run: |
          # Obtener tag anterior
          PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^)

          # Rollback a versión anterior
          az containerapp update \
            --name backend-java-prod \
            --resource-group ${{ env.RESOURCE_GROUP }}-prod \
            --image ${{ env.AZURE_CONTAINER_REGISTRY }}/backend-java:$PREVIOUS_TAG

      - name: Notify team on success
        if: success()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "🚀 Production deployment successful",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*Production Deployment*\n✅ Successfully deployed to production\nCommit: ${{ github.sha }}\nDeployed by: ${{ github.actor }}\nURL: https://empresa.com"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_PROD }}

      - name: Notify team on failure
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "⚠️ Production deployment failed",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*Production Deployment Failed*\n❌ Deployment failed and was rolled back\nCommit: ${{ github.sha }}\nDeployed by: ${{ github.actor }}\n@oncall Please investigate"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_PROD }}

Configuración de Azure Container Apps

Cada servicio necesita su Container App configuration:

# infrastructure/azure/backend-java-containerapp.yaml

apiVersion: 2022-03-01
kind: ContainerApp
location: eastus
name: backend-java-prod
properties:
  managedEnvironmentId: /subscriptions/{subscription-id}/resourceGroups/empresa-prod-rg/providers/Microsoft.App/managedEnvironments/empresa-prod-env
  configuration:
    ingress:
      external: true
      targetPort: 8080
      transport: http
      allowInsecure: false
      traffic:
        - weight: 100
          latestRevision: true
    secrets:
      - name: database-url
        value: "{database-connection-string}"
      - name: jwt-secret
        value: "{jwt-secret-key}"
    registries:
      - server: empresa.azurecr.io
        username: empresa
        passwordSecretRef: registry-password
    dapr:
      enabled: false
  template:
    containers:
      - name: backend-java
        image: empresa.azurecr.io/backend-java:latest
        resources:
          cpu: 1.0
          memory: 2Gi
        env:
          - name: ENVIRONMENT
            value: "production"
          - name: DATABASE_URL
            secretRef: database-url
          - name: JWT_SECRET
            secretRef: jwt-secret
          - name: FEATURE_FLAG_SERVICE_URL
            value: "https://flags.empresa.com"
        probes:
          - type: liveness
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
          - type: readiness
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
    scale:
      minReplicas: 2
      maxReplicas: 10
      rules:
        - name: http-rule
          http:
            metadata:
              concurrentRequests: "50"

Monitoreo y observabilidad

Configura Application Insights para monitorear las aplicaciones:

# .github/workflows/monitor.yml

name: Post-Deployment Monitoring

on:
  workflow_run:
    workflows: ["Deploy to Environments"]
    types: [completed]
    branches: [main]

jobs:
  monitor-deployment:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

    steps:
      - name: Wait for services to stabilize
        run: sleep 120

      - name: Check error rates
        run: |
          # Query Application Insights
          az monitor app-insights metrics show \
            --app ${{ secrets.APP_INSIGHTS_ID }} \
            --metric requests/failed \
            --interval PT1H \
            --aggregation avg \
            --start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%S) \
            --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
            --output json > error_rate.json

          ERROR_RATE=$(jq '.value[0].timeseries[0].data[-1].average' error_rate.json)

          if (( $(echo "$ERROR_RATE > 5" | bc -l) )); then
            echo "::error::Error rate is $ERROR_RATE% (threshold: 5%)"
            exit 1
          fi

      - name: Check response times
        run: |
          az monitor app-insights metrics show \
            --app ${{ secrets.APP_INSIGHTS_ID }} \
            --metric requests/duration \
            --interval PT1H \
            --aggregation avg \
            --start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%S) \
            --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
            --output json > response_time.json

          AVG_RESPONSE_TIME=$(jq '.value[0].timeseries[0].data[-1].average' response_time.json)

          if (( $(echo "$AVG_RESPONSE_TIME > 2000" | bc -l) )); then
            echo "::warning::Average response time is ${AVG_RESPONSE_TIME}ms (target: <2000ms)"
          fi

      - name: Alert on anomalies
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "⚠️ Deployment monitoring detected anomalies",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*Deployment Monitoring Alert*\nAnomalías detectadas después del deployment\nCommit: ${{ github.sha }}\n@oncall Por favor revisar"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_ONCALL }}

El rol de QA en Trunk-Based Development

En Trunk-Based Development, QA no espera al final del sprint para empezar a probar. Participa continuamente durante todo el desarrollo.

Qué debe revisar QA

En cada deploy a Staging (varias veces al día):

  1. Smoke tests automatizados

    • Los flujos críticos funcionan
    • No hay regresiones obvias en funcionalidad existente
    • La aplicación carga y es navegable
  2. Validación de features en desarrollo

    • Si hay feature flags activados para QA, probar esas features
    • Validar comportamiento esperado según acceptance criteria
    • Encontrar edge cases
  3. Pruebas exploratorias

    • Probar caminos no obvios
    • Intentar romper el sistema
    • Verificar experiencia de usuario

Qué NO debe revisar QA en cada commit:

  1. No prueba features incompletas que aún no tienen feature flag activado
  2. No hace regresión completa en cada commit (solo en releases)
  3. No valida detalles de UX hasta que la feature esté marcada como completa

Workflow de QA en un sprint típico

Inicio del sprint (Día 1):

  1. QA asiste a planning y refinement
  2. Entiende las user stories y acceptance criteria
  3. Prepara test cases para las nuevas features
  4. Configura test data en staging

Durante el sprint (Días 2-12):

  1. Cada mañana: Revisa qué se deployó a staging el día anterior
  2. Múltiples veces al día: Ejecuta smoke tests automatizados
  3. Cuando hay feature flags activados para QA: Prueba esas features específicamente
  4. Documenta bugs inmediatamente: Crea tickets en Jira, asigna al desarrollador
  5. Comunicación continua: Slack con devs para aclarar comportamientos esperados

Final del sprint (Días 13-14):

  1. Regresión completa: Verifica que nada se rompió
  2. Validación de acceptance criteria: Todas las stories cumplen lo prometido
  3. Sign-off: Aprueba features para activación en producción

Herramientas para QA

Test automation framework:

// frontend-react/e2e/tests/profile-picture-upload.spec.ts

import { test, expect } from "@playwright/test";

test.describe("Profile Picture Upload", () => {
  test.beforeEach(async ({ page }) => {
    // Login como usuario de prueba
    await page.goto("https://staging.empresa.com/login");
    await page.fill('[name="email"]', "qa-tester@empresa.com");
    await page.fill('[name="password"]', "QA_Test_Password_123");
    await page.click('button[type="submit"]');

    // Activar feature flag para este usuario
    await page.evaluate(() => {
      localStorage.setItem(
        "feature_flags",
        JSON.stringify({
          "profile-picture-upload": true,
        })
      );
    });

    await page.goto("https://staging.empresa.com/profile");
  });

  test("should allow uploading a profile picture", async ({ page }) => {
    // Verificar que el componente de upload está visible
    await expect(page.locator(".profile-picture-upload")).toBeVisible();

    // Seleccionar archivo
    const fileInput = page.locator('input[type="file"]');
    await fileInput.setInputFiles("test-data/test-profile-picture.jpg");

    // Esperar preview
    await expect(page.locator(".profile-picture-preview")).toBeVisible();

    // Click upload
    await page.click('button:has-text("Upload Picture")');

    // Verificar loading state
    await expect(page.locator('button:has-text("Uploading")')).toBeVisible();

    // Esperar éxito (timeout 10s)
    await expect(page.locator(".upload-success-message")).toBeVisible({
      timeout: 10000,
    });

    // Verificar que la imagen se muestra
    const profileImage = page.locator(".current-picture img");
    await expect(profileImage).toHaveAttribute("src", /^https:\/\//);
  });

  test("should validate file size", async ({ page }) => {
    // Intentar subir archivo muy grande
    const fileInput = page.locator('input[type="file"]');
    await fileInput.setInputFiles("test-data/large-file-10mb.jpg");

    // Verificar mensaje de error
    await expect(
      page.locator('.error-message:has-text("must be less than 5MB")')
    ).toBeVisible();
  });

  test("should validate file type", async ({ page }) => {
    // Intentar subir archivo no-imagen
    const fileInput = page.locator('input[type="file"]');
    await fileInput.setInputFiles("test-data/document.pdf");

    // Verificar mensaje de error
    await expect(
      page.locator('.error-message:has-text("image file")')
    ).toBeVisible();
  });
});
# Ejecutar tests E2E en staging
cd frontend-react
npx playwright test --project=chromium --grep="Profile Picture Upload"

API testing con Postman/Newman:

// postman/collections/profile-api.json

{
  "info": {
    "name": "Profile API Tests",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "item": [
    {
      "name": "Upload Profile Picture",
      "event": [
        {
          "listen": "test",
          "script": {
            "exec": [
              "pm.test('Status code is 200', function () {",
              "    pm.response.to.have.status(200);",
              "});",
              "",
              "pm.test('Response has picture_url', function () {",
              "    var jsonData = pm.response.json();",
              "    pm.expect(jsonData).to.have.property('picture_url');",
              "    pm.expect(jsonData.picture_url).to.match(/^https:\\/\\//);",
              "});",
              "",
              "pm.test('Response time is less than 5000ms', function () {",
              "    pm.expect(pm.response.responseTime).to.be.below(5000);",
              "});"
            ]
          }
        }
      ],
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Authorization",
            "value": "Bearer {{auth_token}}"
          }
        ],
        "body": {
          "mode": "formdata",
          "formdata": [
            {
              "key": "file",
              "type": "file",
              "src": "test-profile-picture.jpg"
            }
          ]
        },
        "url": {
          "raw": "{{base_url}}/api/v1/profile/picture",
          "host": ["{{base_url}}"],
          "path": ["api", "v1", "profile", "picture"]
        }
      }
    }
  ]
}
# Ejecutar tests de API contra staging
newman run postman/collections/profile-api.json \
  --environment postman/environments/staging.json \
  --reporters cli,json \
  --reporter-json-export test-results.json

Comunicación QA-Dev

En Trunk-Based Development, la comunicación rápida es crítica:

Slack channels recomendados:

  • #dev-commits: Feed de todos los commits a main
  • #qa-bugs: Bugs encontrados por QA
  • #staging-deploys: Notificaciones de deploys a staging
  • #qa-dev-sync: Canal para preguntas rápidas QA ↔ Dev

Template de bug report:

**🐛 Bug Report**

**Environment:** Staging
**Commit:** abc123def (link to GitHub commit)
**Feature Flag:** profile-picture-upload (enabled for QA)

**Steps to Reproduce:**

1. Login as qa-tester@empresa.com
2. Navigate to /profile
3. Click "Select Photo"
4. Choose image larger than 5MB
5. Click "Upload"

**Expected Behavior:**
Should show error message "Image must be less than 5MB"

**Actual Behavior:**
Upload button becomes disabled indefinitely. No error message shown.
Console shows: TypeError: Cannot read property 'size' of undefined

**Impact:** High - blocks user from uploading any images after error
**Urgency:** Medium - feature is behind flag, not in production yet

**Screenshots/Video:** [attached]

**Assigned to:** @developer-who-worked-on-this

Tiempo objetivo de resolución:

  • Critical bugs (bloquean QA): < 4 horas
  • High impact bugs: < 1 día
  • Medium/Low bugs: siguiente sprint si no afectan release actual

Gestión de emergencias y hotfixes

Incluso con Trunk-Based Development y toda la automatización, las emergencias ocurren. Un bug crítico llega a producción. ¿Cómo se maneja?

El proceso de hotfix

Escenario: Un bug crítico se detecta en producción que impide a usuarios hacer checkout en el e-commerce.

Paso 1: Identificación y comunicación (5 minutos)

# Oncall developer recibe alerta
# Slack: #incidents channel

@oncall 🚨 INCIDENT: Checkout failing in production
Error rate: 45% of checkout attempts
Impact: Users cannot complete purchases
Started: 2 minutes ago

Paso 2: Diagnóstico rápido (10-15 minutos)

# Revisar logs en Azure Application Insights
# Identificar: NullPointerException en PaymentController.processPayment()
# Root cause: Reciente cambio en validación de payment method

# Identificar el commit problemático
git log --oneline -20
# Encuentra: a1b2c3d feat(payment): add validation for payment methods

# Confirmar que es el problema
git show a1b2c3d

Paso 3: Fix inmediato (20-30 minutos)

En Trunk-Based Development, NO creas una rama hotfix separada. Trabajas en el trunk:

# Sincronizar con trunk
git checkout main
git pull --rebase origin main

# Crear fix (pequeño y quirúrgico)
# backend-java/src/main/java/com/empresa/payment/PaymentController.java

@PostMapping("/process")
public ResponseEntity<PaymentResponse> processPayment(
    @RequestBody PaymentRequest request
) {
    // FIX: Add null check before validation
    if (request.getPaymentMethod() == null) {
        throw new BadRequestException("Payment method is required");
    }

    // Validación que causó el problema
    validatePaymentMethod(request.getPaymentMethod());

    // Resto del código...
}

# Test local rápido
cd backend-java
mvn test -Dtest=PaymentControllerTest

# Commit con prioridad alta
git add backend-java/src/main/java/com/empresa/payment/PaymentController.java
git commit -m "fix(payment): add null check for payment method

HOTFIX: Prevents NullPointerException when payment method is missing

Root cause: Validation logic assumed payment method was always present
Impact: 45% of checkout attempts failing
Duration: 15 minutes

Fixes: #INC-456"

# Push al trunk inmediatamente
git push origin main

Paso 4: Deploy fast-track a producción (10 minutos)

# Trigger manual deploy to production (salta staging)
gh workflow run deploy.yml \
  --ref main \
  -f environment=production \
  -f skip_staging=true \
  -f reason="Hotfix for checkout failures"

O si tienes configuración especial para hotfixes:

# .github/workflows/hotfix-deploy.yml

name: Hotfix Deploy

on:
  workflow_dispatch:
    inputs:
      commit_sha:
        description: "Commit SHA to deploy"
        required: true
      incident_id:
        description: "Incident ID"
        required: true

jobs:
  hotfix-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
        with:
          ref: ${{ github.event.inputs.commit_sha }}

      - name: Validate this is a hotfix commit
        run: |
          COMMIT_MSG=$(git log -1 --pretty=%B)
          if [[ ! $COMMIT_MSG =~ ^fix.*HOTFIX ]]; then
            echo "Error: Not a hotfix commit"
            exit 1
          fi

      - name: Build and deploy to production
        # Build steps...

      - name: Verify deployment
        run: |
          sleep 30
          curl -f https://api.empresa.com/health || exit 1

      - name: Update incident
        run: |
          # Actualizar incident en sistema de tracking
          curl -X POST https://incidents.empresa.com/api/incidents/${{ github.event.inputs.incident_id }}/comments \
            -H "Authorization: Bearer ${{ secrets.INCIDENTS_API_TOKEN }}" \
            -d "Hotfix deployed: commit ${{ github.event.inputs.commit_sha }}"

Paso 5: Verificación (5 minutos)

# Monitorear error rate en Application Insights
# Debería bajar de 45% a <1% en minutos

# Verificar en producción
curl -X POST https://api.empresa.com/payment/process \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TEST_TOKEN" \
  -d '{"amount": 100, "currency": "USD"}'

# Confirmar que falla apropiadamente (con 400, no 500)
# Expected: 400 Bad Request - "Payment method is required"

Paso 6: Post-mortem y seguimiento

# Incident Report: INC-456

**Duration:** 18 minutes (14:32 - 14:50 UTC)
**Impact:** 45% of checkout attempts failed
**Revenue impact:** ~$2,500 in lost transactions

**Timeline:**

- 14:32: Incident detected by monitoring
- 14:35: Oncall developer started investigation
- 14:40: Root cause identified (commit a1b2c3d)
- 14:42: Fix committed to trunk
- 14:45: Deploy to production initiated
- 14:48: Fix verified in production
- 14:50: Error rate returned to normal

**Root Cause:**
Validation logic for payment methods was added without null check.
Payment method field is optional in the API but required by new validation.

**Fix:**
Added null check before validation. Returns 400 Bad Request if missing.

**Why it wasn't caught:**

- Unit tests existed but didn't cover null case
- Integration tests used valid payment methods
- Staging testing didn't exercise this edge case

**Action Items:**

- [ ] Add test case for null payment method (@dev-lead)
- [ ] Review validation logic in other endpoints (@team)
- [ ] Add contract tests for required vs optional fields (@qa-lead)
- [ ] Update API documentation to clarify field requirements (@tech-writer)

**Sprint retro topic:**
How can we improve test coverage for edge cases?

Cómo NO manejar hotfixes

❌ Antipattern: Crear una rama hotfix separada

# NO HAGAS ESTO
git checkout -b hotfix/checkout-bug production
# ... hacer fix
git commit
git push
# Ahora tienes dos branches divergentes: main y hotfix
# Necesitas mergear de vuelta a main, posibles conflictos

Por qué es malo:

  • Crea divergencia entre main y production
  • El fix debe mergearse de vuelta a main (más pasos)
  • Posibles conflictos si main avanzó
  • Añade complejidad innecesaria

✅ Trunk-Based approach:

# HAZ ESTO
git checkout main
git pull --rebase origin main
# ... hacer fix
git commit
git push origin main
# Deploy a producción desde main
# Main sigue siendo la única fuente de verdad

Cambios de prioridad mid-sprint

Escenario: Estás en día 5 del sprint trabajando en Feature A. Product Owner pide cambiar prioridad a Feature B urgente.

Paso 1: Evaluar el trabajo en progreso

# Ver qué trabajo tienes sin commitear
git status

# Si tienes cambios uncommitted:
# Opción A: Commitear trabajo incompleto (con feature flag off)
git add .
git commit -m "feat(feature-a): partial implementation (WIP)

Implements database schema and models.
Business logic and UI pending.

Feature flag: feature-a (disabled)
Part of: USER-234"

git push origin main

# Opción B: Guardar en stash si no quieres commitear incompleto
git stash push -m "WIP on feature A, switching to feature B"

Paso 2: Cambiar contexto a nueva prioridad

# Sincronizar trunk
git checkout main
git pull --rebase origin main

# Empezar Feature B
# Trabajo normal de Trunk-Based Development

Paso 3: Retomar Feature A cuando sea prioritaria nuevamente

# Si commiteaste (Opción A):
# Simplemente continúa desde donde quedaste
git pull --rebase origin main
# El código está ahí, feature flag desactivado, seguro continuar

# Si usaste stash (Opción B):
git stash list
git stash apply stash@{0}
# Continuar trabajo

Comunicación con el equipo:

**Slack: #dev-team**

@team Cambiando prioridad por request de PO:
❌ Pausando: Feature A (User profile enhancements) - 40% complete
✅ Iniciando: Feature B (Payment gateway integration) - urgent

Feature A status:

- DB schema: ✅ committed to main
- Models: ✅ committed to main
- Business logic: ⏸️ not started
- UI: ⏸️ not started
- Feature flag: disabled

Retomar Feature A: después de completar Feature B (estimado 3 días)

Trunk-Based Development y Scrum: La integración completa

Trunk-Based Development no reemplaza Scrum, lo complementa. Veamos cómo integrar ambos de manera efectiva.

Sprint Planning con Trunk-Based Development

Diferencias en el planning:

Git Flow tradicional:

  • “Esta feature va en la rama feature/X”
  • “Vamos a mergear al final del sprint”
  • Énfasis en features completas

Trunk-Based Development:

  • “Esta feature se integrará incrementalmente durante el sprint”
  • “Usaremos feature flag ‘feature-x’ hasta que esté completa”
  • Énfasis en pasos integrables

Planning meeting estructura:

## Sprint Planning - Sprint 23

**Duración:** 2 semanas
**Equipo:** 6 devs, 2 QA, 1 tech lead, 1 scrum master, 1 PO

### Parte 1: Qué construir (Product Owner)

**Sprint Goal:**
Permitir a usuarios gestionar sus métodos de pago guardados

**User Stories seleccionadas:**

1. USER-301: Como usuario, quiero agregar una tarjeta de crédito para futuros pagos
2. USER-302: Como usuario, quiero ver mis tarjetas guardadas para elegir cuál usar
3. USER-303: Como usuario, quiero eliminar una tarjeta guardada para proteger mi privacidad
4. USER-304: Como usuario, quiero marcar una tarjeta como predeterminada para checkouts rápidos

**Acceptance Criteria detallados para cada story...**

### Parte 2: Cómo construir (Tech Lead + Team)

**Descomposición técnica - USER-301 (Agregar tarjeta):**

**Backend work:**

- [ ] Task 1.1: DB schema para payment_methods table (0.5 days) - @dev1
  - Integrable: Sí, no afecta código existente
  - Feature flag: No necesario
  - Commit target: Día 1
- [ ] Task 1.2: PaymentMethod entity y repository (0.5 days) - @dev1
  - Integrable: Sí, no usado todavía
  - Feature flag: No necesario
  - Commit target: Día 1
- [ ] Task 1.3: Integración con Stripe API para tokenización (1 day) - @dev2
  - Integrable: Sí, con feature flag
  - Feature flag: "payment-methods-add"
  - Commit target: Día 2-3
- [ ] Task 1.4: POST /api/payment-methods endpoint (1 day) - @dev2
  - Integrable: Sí, con feature flag
  - Feature flag: "payment-methods-add"
  - Commit target: Día 3-4

**Frontend work:**

- [ ] Task 1.5: PaymentMethodForm component (1 day) - @dev3
  - Integrable: Sí, pero no integrado en UI todavía
  - Feature flag: "payment-methods-add"
  - Commit target: Día 2-3
- [ ] Task 1.6: Integración en ProfilePage (0.5 days) - @dev3
  - Integrable: Sí, con feature flag
  - Feature flag: "payment-methods-add"
  - Commit target: Día 4
- [ ] Task 1.7: Validación de formulario y UX (0.5 days) - @dev3
  - Integrable: Sí, con feature flag
  - Feature flag: "payment-methods-add"
  - Commit target: Día 4

**Mobile work:**

- [ ] Task 1.8: PaymentMethodScreen (1.5 days) - @dev4
  - Integrable: Sí, con feature flag
  - Feature flag: "payment-methods-add"
  - Commit target: Día 3-5

**QA work:**

- [ ] Task 1.9: Preparar test data en staging (0.5 days) - @qa1
- [ ] Task 1.10: Test cases para agregar tarjeta (ongoing) - @qa1
- [ ] Task 1.11: E2E tests automated (1 day) - @qa1
  - Commit target: Día 5-6

**Dependencias identificadas:**

- Task 1.3 depende de Task 1.2 (necesita entity)
- Task 1.4 depende de Task 1.3 (necesita servicio de tokenización)
- Task 1.6 depende de Task 1.5 (necesita componente)
- Task 1.8 puede empezar después de Task 1.4 (necesita API)

**Feature flags necesarios:**

- "payment-methods-add": Controla toda la funcionalidad de agregar tarjetas
  - Staging: enabled para QA desde día 2
  - Production: disabled hasta sign-off de QA

**Definition of Done para USER-301:**

- [ ] Código en trunk con feature flag
- [ ] Unit tests (>80% coverage)
- [ ] Integration tests pasan
- [ ] QA sign-off en staging
- [ ] Documentation actualizada
- [ ] Feature flag activado en staging
- [ ] Ready para activar en producción (flag disabled inicialmente)

Daily Standup con Trunk-Based Development

Los dailies cambian ligeramente con Trunk-Based Development:

Estructura del daily (15 min máximo):

## Daily Standup - Sprint 23, Día 4

**Dev 1 (Backend Java):**

- ✅ Yesterday: Commiteé DB schema y entities al trunk. PRs merged.
- 🔨 Today: Trabajando en servicio de tokenización con Stripe. Estimado 2 PRs hoy.
- 🚧 Blockers: Ninguno. Necesito Stripe test keys (ya pedí a @dev-ops)

**Dev 2 (Backend Java):**

- ✅ Yesterday: Empecé endpoint de POST /payment-methods. 50% completo.
- 🔨 Today: Terminar endpoint y commitear. Luego empezar endpoint GET.
- 🚧 Blockers: Esperando que @dev1 termine servicio de tokenización para integrar.
  - **Tech lead:** Ok, coordinen por Slack cuando esté listo.

**Dev 3 (Frontend React):**

- ✅ Yesterday: PaymentMethodForm component completo, commiteé al trunk con flag off.
- 🔨 Today: Integrar component en ProfilePage. QA podrá probar hoy en staging.
- 🚧 Blockers: Ninguno.

**Dev 4 (Mobile Flutter):**

- ✅ Yesterday: Setup de PaymentMethodScreen, UI básico.
- 🔨 Today: Integrar con API de backend. Esperando que endpoint esté listo.
- 🚧 Blockers: Soft blocker - endpoint no está listo, pero puedo mockear por ahora.
  - **Tech lead:** @dev2 commitea endpoint hoy, estarás desbloqueado tarde.

**QA 1:**

- ✅ Yesterday: Test data preparado en staging. Documenté test cases.
- 🔨 Today: Empezar testing cuando @dev3 integre en ProfilePage. Probar en web primero.
- 🚧 Blockers: Ninguno. Feature flag ya está configurado para mi usuario.

**QA 2:**

- ✅ Yesterday: Working on automated E2E tests para flujo completo.
- 🔨 Today: Continuar E2E tests. Commitear al trunk cuando estén listos.
- 🚧 Blockers: Ninguno.

**Tech Lead:**

- Notas: Buen progreso. Todos commiteando continuamente al trunk, sin acumulación.
- Recordatorio: Activar feature flag "payment-methods-add" para QA en staging hoy.
- Sprint burn-down: On track para completar USER-301 en 2 días.

Métricas que el Scrum Master trackea:

## Sprint Metrics - Día 4/10

**Commits to trunk:**

- Día 1: 8 commits
- Día 2: 12 commits
- Día 3: 10 commits
- Día 4: 9 commits (hasta ahora)
  **Promedio:** 2 commits/dev/día ✅ Target: >1.5

**PR lead time (tiempo desde creación hasta merge):**

- Día 1: avg 3.2 hours
- Día 2: avg 2.8 hours
- Día 3: avg 4.1 hours
- Día 4: avg 2.5 hours
  **Promedio:** 3.15 hours ✅ Target: <4 hours

**PR size:**

- Promedio: 180 lines changed
- Máximo: 320 lines changed
  ✅ Target: <500 lines

**Test coverage:**

- Backend Java: 83% ✅
- Backend Go: 78% ✅
- Frontend: 81% ✅
- Mobile: 75% ✅
  **Target:** >70%

**Staging deploys:**

- 18 deploys en 4 días
- Promedio: 4.5 deploys/día
- Cero failed deploys ✅

**QA feedback loop:**

- Promedio tiempo bug report → fix → re-test: 4.2 hours ✅
- Target: <6 hours

Sprint Review/Demo

La demo al final del sprint es más impactante con Trunk-Based Development:

## Sprint Review - Sprint 23

**Sprint Goal:** Permitir gestión de métodos de pago guardados
**Resultado:** ✅ Goal alcanzado, todas las stories completas

**Demo en vivo (no slides, software funcionando):**

**PO:** "Voy a demostrar las 4 user stories que completamos este sprint."

1. **USER-301: Agregar tarjeta**
   - [En staging, con feature flag activado]
   - "Voy a mi perfil, sección Payment Methods"
   - "Click en 'Add Card', ingreso datos de tarjeta de prueba"
   - "Stripe tokeniza la tarjeta, se guarda encriptada"
   - "Veo la tarjeta en mi lista (últimos 4 dígitos)"
2. **USER-302: Ver tarjetas guardadas**
   - "Aquí veo mi lista de tarjetas"
   - "Cada una muestra: tipo, últimos 4 dígitos, fecha de expiración"
3. **USER-303: Eliminar tarjeta**
   - "Click en 'Remove' en una tarjeta"
   - "Confirmación de seguridad"
   - "Tarjeta eliminada de la lista"
4. **USER-304: Marcar como predeterminada**
   - "Click en 'Set as Default' en una tarjeta"
   - "Aparece badge de 'Default'"
   - "En checkout, esta tarjeta se pre-selecciona"

**Stakeholder questions:**

**CFO:** "¿Esto está ya en producción?"
**PO:** "Está deployado a producción pero con feature flag desactivado. Activaremos gradualmente: primero empleados internos, luego 10% de usuarios, luego 100%. Total rollout: próxima semana."

**CMO:** "¿Funciona en mobile también?"
**Tech Lead:** "Sí, la misma funcionalidad está en iOS y Android. [Muestra en emulador]"

**Security Officer:** "¿Cómo se almacenan las tarjetas?"
**Tech Lead:** "No almacenamos datos completos de tarjetas. Usamos tokenización de Stripe. Solo guardamos tokens y últimos 4 dígitos. PCI compliant."

**Metrics del sprint:**

- 4 user stories completadas (4 planeadas) ✅
- 73 commits al trunk
- 31 PRs mergeados
- PR lead time promedio: 3.2 hours
- 42 staging deploys
- 0 production incidents
- Test coverage: 81% avg

Sprint Retrospective

Temas específicos de Trunk-Based Development para retrospective:

## Sprint Retrospective - Sprint 23

### Qué funcionó bien ✅

1. **Integración continua fue fluida**

   - No hubo "día de integración" estresante
   - Conflictos de merge fueron mínimos
   - QA pudo probar continuamente durante el sprint

2. **PRs pequeños, reviews rápidas**

   - Promedio 3.2 hours desde PR a merge
   - Nadie bloqueado esperando reviews
   - Code quality se mantuvo alta

3. **Feature flags permitieron flexibilidad**
   - QA pudo probar features incompletas
   - Pudimos deployar a producción sin activar features
   - Rollout gradual planificado es posible

### Qué mejorar 🔧

1. **Algunos PRs fueron muy pequeños**

   - @dev2 creó 3 PRs en 2 horas modificando el mismo archivo
   - Overhead de PR process vs. beneficio de review
   - **Action:** Guidelines sobre cuándo combinar commits en un solo PR

2. **Feature flag cleanup**

   - Tenemos 15 feature flags activos, algunos de hace 3 meses
   - No está claro cuáles son safe to remove
   - **Action:** @tech-lead crear proceso de review mensual de flags

3. **Test coverage bajó en Mobile**
   - Mobile flutter: 75% (target: 80%)
   - Presión de tiempo llevó a saltear algunos tests
   - **Action:** @dev4 agregar tests faltantes en próximo sprint

### Experimentos para próximo sprint 🧪

1. **Trunk commits directos para cambios triviales**

   - Propuesta: Permitir commit directo a trunk sin PR para cambios <20 lines
   - Tipos: typos en docs, small refactors, dependency updates
   - **Action:** Probar durante un sprint, evaluar en retro

2. **QA embedded con dev teams**

   - @qa1 se sienta con backend team
   - @qa2 se sienta con frontend/mobile team
   - Objetivo: Feedback aún más rápido
   - **Action:** Probar durante un sprint

3. **Automated deployment a producción**
   - Actualmente: Manual approval para deploy a producción
   - Propuesta: Auto-deploy después de 24h en staging sin issues
   - Feature flags controlan visibilidad de features
   - **Action:** Discutir con DevOps y management

### Action Items

- [ ] @tech-lead: Documentar guidelines de cuándo hacer PR vs. commit directo
- [ ] @tech-lead: Setup proceso mensual de feature flag review
- [ ] @dev4: Aumentar test coverage en mobile (target: 80%)
- [ ] @scrum-master: Organizar sesión de planning para QA embedded
- [ ] @dev-ops: Investigar auto-deploy a producción con safeguards

Comandos Git esenciales para Trunk-Based Development

Una referencia rápida de los comandos más importantes:

Flujo de trabajo diario

# 1. Inicio del día: Sincronizar con trunk
git checkout main
git fetch origin
git pull --rebase origin main

# 2. Hacer cambios (trabajo normal)
# ... editar archivos ...

# 3. Verificar cambios antes de commit
git status                    # Qué archivos cambiaron
git diff                      # Ver cambios específicos
git diff --staged             # Ver cambios ya staged

# 4. Stage cambios
git add archivo.java          # Stage archivo específico
git add src/main/java/        # Stage directorio
git add .                     # Stage todo (usar con cuidado)

# 5. Commit con mensaje convencional
git commit -m "feat(payment): add credit card validation

- Validate card number using Luhn algorithm
- Check expiration date is future
- Validate CVV length based on card type

Part of: USER-301"

# 6. Antes de push: Asegurar que estás actualizado
git fetch origin
git rebase origin/main        # Rebase tus commits sobre últimos cambios

# 7. Push al trunk
git push origin main

# 8. Si hay conflictos durante rebase
git status                    # Ver archivos en conflicto
# Resolver conflictos manualmente en archivos
git add archivo-resuelto.java
git rebase --continue

# Si el rebase se complica mucho
git rebase --abort            # Abortar y volver a intentar

Trabajar con ramas de vida corta (opcional)

# 1. Crear rama de vida corta desde main actualizado
git checkout main
git pull --rebase origin main
git checkout -b add-payment-validation

# 2. Hacer cambios y commit
git add .
git commit -m "feat(payment): add card validation"

# 3. Push rama para crear PR
git push origin add-payment-validation

# 4. Después de que PR se mergea: Limpiar
git checkout main
git pull --rebase origin main
git branch -d add-payment-validation      # Borrar rama local
git push origin --delete add-payment-validation  # Borrar rama remota

Comandos para mantener historial limpio

# Ver historial reciente
git log --oneline -10
git log --graph --oneline -20

# Amend último commit (antes de push)
git commit --amend -m "nuevo mensaje"
git commit --amend --no-edit             # Agregar cambios sin cambiar mensaje

# Rebase interactivo para limpiar commits locales
git rebase -i HEAD~3                     # Últimos 3 commits
# En el editor:
# - pick: mantener commit
# - squash: combinar con commit anterior
# - reword: cambiar mensaje
# - drop: eliminar commit

# Ver diferencias con trunk
git fetch origin
git log origin/main..HEAD                # Commits locales no pusheados
git diff origin/main..HEAD               # Cambios no pusheados

Recuperación de emergencias

# Ver reflog (historial de todos los cambios)
git reflog

# Recuperar commit "perdido"
git cherry-pick abc123                   # Aplicar commit específico

# Deshacer último commit (mantener cambios)
git reset --soft HEAD~1

# Deshacer último commit (descartar cambios)
git reset --hard HEAD~1                  # ⚠️ PELIGROSO

# Descartar cambios locales
git checkout -- archivo.java             # Descartar cambios en archivo
git reset --hard origin/main             # ⚠️ Resetear a trunk (perder todo local)

# Stash: Guardar trabajo sin commitear
git stash push -m "WIP on feature X"
git stash list                           # Ver stashes
git stash apply stash@{0}                # Aplicar stash
git stash drop stash@{0}                 # Borrar stash

Comandos para troubleshooting

# Encontrar qué commit introdujo un bug
git bisect start
git bisect bad                           # Commit actual tiene bug
git bisect good abc123                   # Commit abc123 no tenía bug
# Git checkout commits intermedios, tú pruebas y marcas good/bad
git bisect good    # Este commit no tiene bug
git bisect bad     # Este commit tiene bug
# Git eventualmente encuentra el commit culpable
git bisect reset   # Terminar bisect

# Ver quién modificó línea específica
git blame archivo.java                   # Ver autor de cada línea
git blame -L 50,60 archivo.java          # Solo líneas 50-60

# Buscar en historial
git log --grep="payment"                 # Commits con "payment" en mensaje
git log -S"functionName"                 # Commits que agregaron/quitaron "functionName"
git log --author="Juan"                  # Commits de autor específico

# Ver cambios en archivo específico
git log --follow -- archivo.java         # Historial de archivo (incluso si se renombró)
git log -p archivo.java                  # Historial con diffs

Aliases útiles

Agregar al ~/.gitconfig:

[alias]
    # Commits
    cm = commit -m
    ca = commit --amend
    cane = commit --amend --no-edit

    # Status y diff
    st = status -sb
    df = diff
    dfs = diff --staged

    # Log
    lg = log --graph --oneline --decorate --all -20
    last = log -1 HEAD --stat

    # Branches
    br = branch -v
    bra = branch -av

    # Sync
    sync = !git fetch origin && git rebase origin/main
    up = pull --rebase origin main

    # Cleanup
    cleanup = !git branch --merged | grep -v '\\*\\|main\\|develop' | xargs -n 1 git branch -d

    # Stash
    save = stash push -m
    pop = stash pop

    # Undo
    undo = reset --soft HEAD~1
    nuke = reset --hard HEAD~1

    # Push
    pushf = push --force-with-lease        # Safe force push

Uso:

git sync                                   # Sincronizar con trunk
git lg                                     # Ver log gráfico
git save "WIP on feature X"                # Stash rápido
git cleanup                                # Limpiar ramas mergeadas

Qué cubre Trunk-Based Development

Es importante entender qué problemas resuelve Trunk-Based Development y qué problemas NO resuelve.

Qué SÍ cubre

1. Integración continua y conflictos de merge

Trunk-Based Development elimina casi completamente los conflictos de merge complejos. Al integrar continuamente, los conflictos que aparecen son pequeños y fáciles de resolver.

Problema que resuelve:

  • Conflictos de merge masivos al final del sprint
  • Branches que divergen durante semanas
  • Código que funciona en una rama pero no en trunk

Cómo lo resuelve:

  • Integración múltiple veces al día
  • Conflictos aparecen cuando son pequeños
  • El trunk siempre refleja el estado real del código

2. Feedback lento de integración

En modelos tradicionales, descubres problemas de integración semanas después de escribir el código.

Problema que resuelve:

  • “Funcionaba en mi rama” pero rompe cuando se integra
  • Incompatibilidades arquitectónicas detectadas tarde
  • Refactors grandes que bloquean a todo el equipo

Cómo lo resuelve:

  • CI/CD ejecuta tests en cada commit
  • Problemas de integración se detectan en minutos
  • Feedback inmediato permite corrección rápida

3. Visibilidad y coordinación del equipo

Trunk-Based Development fuerza transparencia sobre qué está trabajando cada quien.

Problema que resuelve:

  • No saber en qué trabajan otros devs
  • Duplicación de trabajo
  • Decisiones arquitectónicas divergentes

Cómo lo resuelve:

  • Todos ven commits en el trunk en tiempo real
  • Imposible trabajar aislado por semanas
  • Comunicación forzada sobre cambios grandes

4. Deployment y releases

Trunk-Based Development facilita deployments frecuentes y seguros.

Problema que resuelve:

  • Deployments complejos que requieren días de preparación
  • “Release days” estresantes con muchos cambios simultáneos
  • Miedo a deployear porque muchas cosas pueden romper

Cómo lo resuelve:

  • El trunk siempre está en estado deployable
  • Feature flags controlan qué se activa
  • Deployments pequeños y frecuentes reducen riesgo

5. Mantenimiento de múltiples versiones

Trunk-Based Development simplifica radicalmente el mantenimiento.

Problema que resuelve:

  • Mantener múltiples branches de release
  • Backportar fixes a versiones antiguas
  • Confusión sobre qué está en qué versión

Cómo lo resuelve:

  • Una sola rama principal
  • Todo va al trunk primero
  • Versiones anteriores se manejan con tags, no branches activos

6. Onboarding de nuevos desarrolladores

Trunk-Based Development es más simple de entender para nuevos miembros.

Problema que resuelve:

  • Branching models complejos que confunden a nuevos devs
  • Documentación extensa sobre “cómo y cuándo crear ramas”
  • Miedo a “hacer algo mal” con Git

Cómo lo resuelve:

  • Modelo simple: “trabajo en trunk, commiteo frecuentemente”
  • Menos decisiones sobre branches
  • CI/CD previene errores automáticamente

Qué NO cubre

1. Calidad del código

Trunk-Based Development NO garantiza código de calidad.

Lo que NO resuelve:

  • Mal diseño arquitectónico
  • Código difícil de mantener
  • Falta de tests adecuados
  • Deuda técnica

Lo que necesitas además:

  • Code reviews rigurosas
  • Estándares de código claros
  • Tests comprehensivos
  • Refactoring continuo

Ejemplo: Puedes commitear código mediocre al trunk 10 veces al día. Trunk-Based Development hace que ese código esté integrado rápidamente, pero no lo hace mejor. Necesitas disciplina de ingeniería adicional.

2. Gestión de producto y prioridades

Trunk-Based Development NO decide qué construir.

Lo que NO resuelve:

  • Features que no agregan valor
  • Backlog mal priorizado
  • Falta de visión de producto
  • Desalineación con necesidades de usuario

Lo que necesitas además:

  • Product management competente
  • User research y validación
  • Roadmap claro
  • Métricas de valor de negocio

Ejemplo: Puedes entregar features perfectamente integradas y deployadas que nadie usa. Trunk-Based Development acelera la entrega, pero no garantiza que entregas lo correcto.

3. Comunicación y colaboración del equipo

Trunk-Based Development facilita colaboración técnica, pero no resuelve problemas humanos.

Lo que NO resuelve:

  • Falta de comunicación entre miembros del equipo
  • Silos organizacionales
  • Conflictos interpersonales
  • Falta de confianza

Lo que necesitas además:

  • Cultura de equipo saludable
  • Comunicación clara y frecuente
  • Procesos de resolución de conflictos
  • Psychological safety

Ejemplo: Dos devs pueden estar commiteando al trunk pero nunca hablarse, resultando en código duplicado y arquitectura inconsistente. Trunk-Based Development expone estos problemas, pero no los resuelve.

4. Testing y QA comprehensivo

Trunk-Based Development NO reemplaza testing riguroso.

Lo que NO resuelve:

  • Bugs en lógica de negocio
  • Edge cases no considerados
  • Problemas de usabilidad
  • Bugs que solo aparecen en producción

Lo que necesitas además:

  • Suite de tests automatizados completa
  • QA manual para casos exploratorios
  • Monitoring y alerting en producción
  • User acceptance testing

Ejemplo: Puedes deployear código al trunk que pasa todos los tests pero tiene un bug crítico que solo usuarios reales descubren. Trunk-Based Development te permite arreglar rápido, pero no previene el bug.

5. Complejidad del dominio de negocio

Trunk-Based Development NO simplifica lógica de negocio compleja.

Lo que NO resuelve:

  • Reglas de negocio complicadas
  • Integraciones con sistemas legacy
  • Compliance y regulaciones
  • Procesos de negocio complejos

Lo que necesitas además:

  • Domain-Driven Design
  • Arquitectura apropiada (Clean Architecture, Hexagonal, etc.)
  • Documentación de reglas de negocio
  • Expertos de dominio disponibles

Ejemplo: Un sistema de cálculo de impuestos multinacional es complejo sin importar cómo uses Git. Trunk-Based Development facilita colaborar en esa complejidad, pero no la elimina.

6. Infraestructura y DevOps

Trunk-Based Development NO configura tu infraestructura.

Lo que NO resuelve:

  • Configuración de CI/CD
  • Gestión de ambientes
  • Monitoring y logging
  • Escalabilidad de infraestructura

Lo que necesitas además:

  • Expertise en DevOps
  • Infraestructura como código
  • Herramientas de CI/CD configuradas
  • Cloud infrastructure bien diseñada

Ejemplo: Trunk-Based Development asume que tienes pipelines automatizados. Si no los tienes, necesitas construirlos primero. TBD no es un reemplazo de DevOps, es complementario.

7. Seguridad

Trunk-Based Development NO asegura tu aplicación.

Lo que NO resuelve:

  • Vulnerabilidades de seguridad
  • Manejo inseguro de datos sensibles
  • Falta de autenticación/autorización
  • Compliance con estándares de seguridad

Lo que necesitas además:

  • Security scanning automatizado
  • Code review con foco en seguridad
  • Penetration testing
  • Security training para desarrolladores

Ejemplo: Puedes integrar código vulnerable al trunk rápidamente. Trunk-Based Development hace que sea más fácil arreglarlo cuando se detecta, pero no previene la vulnerabilidad.


Cuándo NO usar Trunk-Based Development

Trunk-Based Development no es para todos. Hay escenarios donde otros modelos son más apropiados.

Equipos pequeños con deployment continuo simple

Escenario: 1-3 desarrolladores, aplicación web simple, deploy manual a VPS.

Por qué TBD puede ser overkill:

  • No hay suficientes conflictos de merge para justificar el proceso
  • Overhead de PRs y validación es innecesario
  • Pueden coordinarse verbalmente

Alternativa mejor:

  • Commits directos a main
  • Deploy manual cuando esté listo
  • Sin PRs, solo commits bien formados

Proyectos con releases poco frecuentes

Escenario: Software empaquetado que se libera cada 6-12 meses.

Por qué TBD no encaja bien:

  • No necesitas integración continua cuando no hay deployment continuo
  • Features pueden necesitar desarrollarse durante meses antes de release
  • Clientes esperan versiones estables, no builds diarios

Alternativa mejor:

  • Git Flow con ramas de release
  • Desarrollo en ramas de larga duración
  • Release branches con cherry-picking de fixes

Equipos distribuidos sin overlap de horario

Escenario: Equipo en 3 continentes sin horario laboral compartido.

Por qué TBD es difícil:

  • No hay nadie para revisar PRs rápidamente
  • Desarrolladores trabajando en trunk en diferentes horarios causa conflictos
  • Comunicación sincrónica sobre cambios es imposible

Alternativa mejor:

  • Feature branches de duración media
  • Integración asíncrona con merges planificados
  • Más autonomía por región

Proyectos open source con muchos contributors externos

Escenario: Proyecto open source con 100+ contributors externos ocasionales.

Por qué TBD no funciona:

  • Contributors externos no pueden commitear directo a trunk
  • Calidad de código es variable
  • Reviews deben ser exhaustivas antes de merge

Alternativa mejor:

  • Fork-based workflow
  • PRs de forks externos
  • Maintainers controlan qué entra al trunk
  • Branches de feature si es necesario

Organizaciones con regulación estricta

Escenario: Software médico, financiero, o aeroespacial con procesos de aprobación obligatorios.

Por qué TBD puede no cumplir requisitos:

  • Pueden requerir aprobaciones formales antes de merge
  • Auditorías requieren rastrear exactamente qué se liberó y cuándo
  • Separation of duties: quien escribe no puede mergear

Alternativa mejor:

  • Proceso de release formal con branches
  • Aprobaciones multi-nivel
  • Auditabilidad explícita
  • Tags y branches para releases certificadas

Equipos sin madurez en testing

Escenario: Equipo que no tiene tests automatizados o cultura de testing.

Por qué TBD sería peligroso:

  • Trunk-Based Development asume que CI valida cada commit
  • Sin tests, integración continua es integración de bugs continuamente
  • Feature flags sin tests pueden romper producción silenciosamente

Qué hacer primero:

  1. Construir suite de tests automatizados
  2. Configurar CI/CD básico
  3. Entrenar equipo en TDD o al menos testing
  4. ENTONCES adoptar Trunk-Based Development

Proyectos con muchas variantes de código

Escenario: Software que tiene 10+ versiones customizadas para diferentes clientes.

Por qué TBD es insuficiente:

  • Un solo trunk no puede representar múltiples versiones divergentes
  • Customizaciones por cliente son difíciles de gestionar con feature flags
  • Merge de mejoras entre versiones es complejo

Alternativa mejor:

  • Branch por cliente o versión
  • Core común con branches de customización
  • Cherry-picking de features entre branches

Métricas para evaluar éxito de Trunk-Based Development

¿Cómo sabes si Trunk-Based Development está funcionando para tu equipo? Estas métricas te lo dirán:

Métricas de proceso Git

1. Frecuencia de commits al trunk

Objetivo: >1.5 commits por desarrollador por día

# Medir commits por desarrollador en última semana
git log --since="1 week ago" --pretty=format:"%an" | sort | uniq -c | sort -rn

Interpretación:

  • <1 commit/día: Desarrolladores acumulando trabajo, no integrando continuamente
  • 1-2 commits/día: Buen balance
  • 3 commits/día: Excelente integración continua

2. Tamaño de commits

Objetivo: <300 líneas changed por commit (promedio)

# Ver estadísticas de commits recientes
git log --since="1 week ago" --shortstat --pretty=format:"%H" | \
awk '/^ [0-9]/ { files+=$1; inserted+=$4; deleted+=$6 } END \
{ print "Avg changed per commit:", (inserted+deleted)/NR }'

Interpretación:

  • <100 líneas: Quizá demasiado granular, overhead de commits
  • 100-300 líneas: Ideal, commits incrementales significativos
  • 500 líneas: Commits muy grandes, difíciles de revisar

3. Lead time de PRs

Objetivo: <4 horas desde creación hasta merge

Cómo medir:

  • GitHub API o GitHub Insights
  • Track tiempo entre pull_request.opened y pull_request.closed (con merge)

Interpretación:

  • <2 horas: Excelente, reviews rápidas
  • 2-4 horas: Buen ritmo
  • 8 horas: Bottleneck en reviews, desarrolladores esperando

4. Conflictos de merge

Objetivo: <5% de merges tienen conflictos

Cómo medir:

# Contar merges con conflictos en último mes
git log --since="1 month ago" --grep="Merge" --grep="conflict" -i | wc -l

Interpretación:

  • 0-5%: Excelente, integraciones fluidas
  • 5-10%: Aceptable, pero monitor
  • 10%: Problema, probablemente branches de larga duración

Métricas de CI/CD

5. Build success rate

Objetivo: >95% de builds pasan

Cómo medir:

  • GitHub Actions metrics
  • Count: successful builds / total builds

Interpretación:

  • 95%: Saludable, código integrado es generalmente bueno

  • 90-95%: Aceptable, pero revisar por qué fallan
  • <90%: Problema serio, código roto llegando a trunk

6. Deploy frequency

Objetivo: Múltiples deploys por día a staging, al menos uno a producción por día

Cómo medir:

  • Logs de deployment en Azure Container Apps
  • GitHub Actions history

Interpretación:

  • 5 deploys/día a staging: Excelente flujo

  • 1-5 deploys/día: Buen ritmo
  • <1 deploy/día: No aprovechando TBD completamente

7. Mean Time to Recovery (MTTR)

Objetivo: <1 hora para resolver incidents en producción

Cómo medir:

  • Tiempo desde incident detected hasta resolved
  • Track en sistema de incidents

Interpretación:

  • <30 min: Excelente capacidad de respuesta
  • 30 min - 1 hora: Buen tiempo
  • 2 horas: Proceso de hotfix necesita mejoras

Métricas de calidad

8. Test coverage

Objetivo: >75% coverage en todo el codebase

Cómo medir:

  • JaCoCo reports (Java)
  • go test -cover (Go)
  • jest —coverage (JavaScript)

Interpretación:

  • 80%: Excelente, código bien testeado

  • 70-80%: Buen nivel
  • <70%: Insuficiente para TBD seguro

9. Bug escape rate

Objetivo: <5% de bugs llegan a producción sin detectarse en staging

Cómo medir:

  • Bugs found in production / Total bugs found
  • Track en Jira o issue tracker

Interpretación:

  • <5%: QA y testing efectivos
  • 5-10%: Aceptable
  • 10%: Proceso de QA necesita mejoras

10. Feature flag technical debt

Objetivo: <10 feature flags activos mayores a 1 mes

Cómo medir:

SELECT COUNT(*)
FROM feature_flags
WHERE created_at < NOW() - INTERVAL '1 month'
AND deleted_at IS NULL;

Interpretación:

  • <10 flags: Buena limpieza
  • 10-20 flags: Revisar cuáles se pueden remover
  • 20 flags: Deuda técnica acumulándose

Métricas de equipo

11. Sprint velocity stability

Objetivo: Variación <20% entre sprints

Cómo medir:

  • Story points completed por sprint
  • Calcular coefficient of variation

Interpretación:

  • <15% variación: Equipo predecible
  • 15-25% variación: Normal
  • 25% variación: Problemas de estimation o capacity

12. Code review participation

Objetivo: Cada dev participa en reviews (no solo el tech lead)

Cómo medir:

# Reviewers de PRs en último mes
gh pr list --state merged --limit 100 --json reviews --jq \
'.[] | .reviews[] | .author.login' | sort | uniq -c

Interpretación:

  • Distribución equitativa: Cultura de code review saludable
  • Concentrado en 1-2 personas: Bottleneck, knowledge silos

Dashboard de métricas

Configura un dashboard que todo el equipo pueda ver:

## Trunk-Based Development Metrics - Sprint 23

### Git Activity

- ✅ Commits to trunk: 73 (target: >60 per sprint)
- ✅ Avg commits per dev per day: 1.8 (target: >1.5)
- ✅ Avg PR size: 245 lines (target: <300)
- ✅ Avg PR lead time: 3.2 hours (target: <4)
- ✅ Merge conflicts: 2.7% (target: <5%)

### CI/CD Health

- ✅ Build success rate: 96.2% (target: >95%)
- ✅ Staging deploys: 42 (target: >30)
- ⚠️ Production deploys: 5 (target: >7)
- ✅ MTTR: 42 minutes (target: <60)

### Quality

- ✅ Test coverage: 81.3% (target: >75%)
- ✅ Bug escape rate: 4.1% (target: <5%)
- ⚠️ Feature flags >1 month: 12 (target: <10)

### Team

- ✅ Sprint velocity: 42 points (variance: 14%)
- ✅ Code review participation: All 6 devs active

**Actions this sprint:**

- ⚠️ Increase production deploy frequency
- ⚠️ Review and clean up old feature flags

Recursos y referencias

Libros recomendados

  1. “Accelerate” - Nicole Forsgren, Jez Humble, Gene Kim

  2. “Continuous Delivery” - Jez Humble, David Farley

  3. “Team Topologies” - Matthew Skelton, Manuel Pais

    • Cómo organizar equipos para flow óptimo
    • Complementa Trunk-Based Development con estructura organizacional
    • https://teamtopologies.com/

Sitios web y documentación

  1. TrunkBasedDevelopment.com

  2. GitHub Flow vs Git Flow vs Trunk-Based Development

  3. Google’s Engineering Practices

  4. Martin Fowler on Feature Toggles

Herramientas

Feature Flag Services:

CI/CD:

Git tools:

Monitoring:

Estudios y estadísticas

State of DevOps Report - DORA Metrics:

El reporte anual de DevOps Research and Assessment (DORA) consistentemente muestra que organizaciones que practican Trunk-Based Development tienen:

  • 46x más deploys por año comparado con organizaciones de baja performance
  • 440x faster lead time desde commit hasta producción
  • 170x faster MTTR (Mean Time to Recovery)
  • 5x lower change failure rate

Fuente: https://dora.dev/

Google’s Monorepo Study:

Google mantiene un monorepo con >2 billones de líneas de código y 25,000+ desarrolladores commiteando a trunk:

  • ~40,000 commits al día al trunk
  • Todos los desarrolladores ven todos los cambios
  • 95% de changes deployados automáticamente

Paper: “Why Google Stores Billions of Lines of Code in a Single Repository” https://cacm.acm.org/magazines/2016/7/204032-why-google-stores-billions-of-lines-of-code-in-a-single-repository/

Microsoft’s Engineering System:

Estudio sobre cómo Microsoft migró de branches de larga duración a Trunk-Based Development:

  • Reducción de 45% en merge conflicts
  • 30% mejora en velocidad de features
  • 60% reducción en time to fix bugs

Paper: “The Impact of Trunk-Based Development on Integration” - Microsoft Research


Conclusión: El camino hacia Trunk-Based Development

Trunk-Based Development no es una técnica aislada de Git. Es una filosofía completa que abarca desarrollo, testing, deployment, y colaboración. Cuando se implementa correctamente, transforma cómo equipos ágiles construyen software.

El valor real de Trunk-Based Development

Al final del día, Trunk-Based Development resuelve un problema fundamental: la mayoría de equipos gasta demasiado tiempo en “plumbing” (integraciones, merges, coordinación de releases) y no suficiente tiempo construyendo valor para usuarios.

Cuando eliminas branches de larga duración, reduces dramáticamente el overhead de integración. Cuando usas feature flags, desacoplas deployment de release. Cuando automatizas validación, reduces el tiempo de review. Todo esto se suma a una cosa: más tiempo creando valor, menos tiempo lidiando con infraestructura de desarrollo.

El camino de adopción

No puedes adoptar Trunk-Based Development de la noche a la mañana. Es una transición que requiere varios meses:

Mes 1-2: Fundamentos

  • Configura CI/CD robusto
  • Implementa test automation básico
  • Entrena equipo en commits pequeños
  • Configura conventional commits

Mes 3-4: Transición gradual

  • Reduce lifetime de feature branches a <3 días
  • Implementa feature flags básicos
  • Empieza a integrar diariamente
  • Configura protection rules en main

Mes 5-6: Trunk-Based completo

  • Commits directos a trunk o branches <1 día
  • Feature flags para todas las features nuevas
  • Deployment automático a staging
  • Deploy manual controlado a producción

Mes 7+: Optimización

  • Refinamiento continuo de métricas
  • Automated deployment a producción
  • Feature flag lifecycle management
  • Continuous improvement

Señales de que estás listo

Trunk-Based Development funciona cuando tienes:

✅ Suite de tests automatizados con >70% coverage ✅ CI/CD pipeline que ejecuta tests en <10 minutos ✅ Cultura de code review rápido (<4 horas) ✅ Equipo que entiende commits pequeños e incrementales ✅ Management que acepta deployment frecuente ✅ QA integrado con desarrollo, no en cascada

Si no tienes estos fundamentos, trabaja en ellos primero. Trunk-Based Development sin automatización es un desastre.

La decisión más importante

La pregunta no es “¿debería usar Trunk-Based Development?” La pregunta es “¿mi equipo está preparado para el nivel de disciplina y automatización que Trunk-Based Development requiere?”

Si la respuesta es sí, tendrás un equipo que entrega software más rápido, con menos estrés, y más predecibilidad. Si la respuesta es no todavía, ahora sabes qué construir primero.

El futuro del desarrollo de software es integración continua, deployment continuo, y feedback rápido. Trunk-Based Development es una de las mejores formas de llegar ahí. El viaje vale la pena.

Tags

#trunk-based-development #scrum #agile #devops #git #continuous-integration #software-engineering