Cómo hacer cualquier cosa en programación: del criterio al código


Escucha este artículo Google español (es-ES)

Hay una pregunta que parece demasiado grande para responderla bien:

¿Cómo se hace cualquier cosa en programación?

No “cómo se hace un botón”, “cómo se conecta una API” o “cómo se guarda algo en una base de datos”. Eso se puede buscar. La pregunta difícil es otra: cómo piensas el proceso completo para pasar de una idea borrosa a algo que funciona, se entiende, se puede probar y no se rompe al primer cambio.

La respuesta corta sería: paso a paso.

La respuesta útil es esta: primero entiendes el problema, luego reduces el alcance, después diseñas una estructura mínima, haces funcionar el camino principal, añades seguridad y errores desde el principio, pruebas, refactorizas y solo entonces publicas.

No es magia. Es oficio.


1. Antes de programar: definir qué significa “funciona”

El error más común es abrir el editor demasiado pronto. Programar sin definir el resultado esperado es como construir una casa diciendo “ya veremos dónde van las paredes”.

Antes de escribir código, tienes que responder tres preguntas:

  1. Qué quiere hacer el usuario
  2. Qué datos necesita el sistema
  3. Qué significa que esté terminado

Ejemplo: “quiero un CRUD de tareas” suena claro, pero no lo es.

Un CRUD real necesita decisiones:

  • ¿Una tarea tiene título, descripción, estado, fecha límite?
  • ¿Puede borrarse cualquier tarea?
  • ¿Hay usuarios o todas las tareas son públicas?
  • ¿Qué pasa si el título llega vacío?
  • ¿Qué devuelve la API si la tarea no existe?
  • ¿Dónde se guarda la información?
  • ¿Cómo pruebo que todo sigue funcionando mañana?

Hasta que no respondes eso, no estás programando. Estás adivinando.


2. Reducir el problema: la primera versión no es la versión final

La primera versión debe resolver el camino principal, no todos los casos imaginables.

Para un CRUD de tareas, el alcance mínimo puede ser:

  • crear una tarea
  • listar tareas
  • ver una tarea por id
  • actualizar una tarea
  • borrar una tarea

Y nada más.

No autenticación con Google. No roles. No subida de archivos. No notificaciones por email. No dashboard con gráficos. Todo eso puede venir después si hace falta.

Esto no es hacer las cosas mal. Es hacerlas en orden.

Una buena primera versión responde a esta pregunta:

¿Cuál es el flujo más pequeño que demuestra que la idea funciona?

En Clean Code, Robert C. Martin insiste mucho en funciones pequeñas, nombres claros y código legible. Eso ayuda. Pero antes de llegar a esa capa hay un principio más básico: no compliques el problema antes de entenderlo.


3. Diseñar la estructura mínima

La estructura no tiene que ser perfecta. Tiene que ser entendible.

Para un backend pequeño con Node, Express y una base de datos, una estructura razonable puede ser:

src/
  server.ts          # arranque del servidor
  routes/
    tasks.routes.ts  # endpoints HTTP
  controllers/
    tasks.controller.ts
  services/
    tasks.service.ts # reglas de negocio
  repositories/
    tasks.repo.ts    # acceso a base de datos
  db/
    client.ts
  schemas/
    task.schema.ts   # validación de entrada
  errors/
    AppError.ts

No necesitas veinte capas para empezar. Pero sí conviene separar responsabilidades:

  • route: define la URL y el verbo HTTP
  • controller: traduce request/response
  • service: contiene la lógica del caso de uso
  • repository: habla con la base de datos
  • schema: valida lo que entra
  • error: normaliza fallos

La idea es simple: si mañana cambias Express por otro framework, la lógica no debería estar pegada a req y res por todo el proyecto. Y si mañana cambias PostgreSQL por otra cosa, no deberías reescribir toda la aplicación.


4. Hacer una función que funcione, pero con criterio

Una buena función no es la más lista. Es la que se entiende y hace una cosa concreta.

Mala señal:

async function handleTask(req, res) {
  // valida, calcula, consulta la base de datos,
  // decide estados HTTP, formatea fechas y maneja errores
}

Mejor:

async function createTask(input: CreateTaskInput) {
  const task = validateCreateTask(input);

  return tasksRepository.create({
    title: task.title,
    description: task.description ?? null,
    status: 'pending',
  });
}

La función createTask no sabe de Express. No sabe de HTML. No sabe de cookies. Sabe crear una tarea válida.

Ese es el criterio: cada pieza debería tener un motivo claro para cambiar.

Si cambia la validación, tocas el schema.
Si cambia la base de datos, tocas el repository.
Si cambia el formato HTTP, tocas el controller.


5. Seguridad: no se añade al final

La seguridad no es una fase final tipo “ya lo miraré antes de subirlo”. Si la dejas para el final, ya has construido el proyecto con supuestos inseguros.

Desde el primer endpoint hay que pensar en:

  • validación de entrada: no confíes en req.body
  • secretos fuera del código: .env, nunca claves hardcodeadas
  • mensajes de error seguros: no devuelvas trazas internas al usuario
  • consultas parametrizadas u ORM: evita concatenar SQL manualmente
  • límites básicos: tamaño del payload, rate limit si aplica
  • permisos: si hay usuarios, cada operación debe comprobar quién puede hacer qué

Ejemplo de validación:

const createTaskSchema = z.object({
  title: z.string().trim().min(1).max(120),
  description: z.string().trim().max(1000).optional(),
});

Esto parece aburrido. Perfecto. La seguridad buena suele ser aburrida porque evita que pasen cosas interesantes en producción.


6. Control de errores: decidir qué pasa cuando algo falla

Todo código real falla.

Falla porque el usuario manda datos incorrectos. Falla porque la base de datos no responde. Falla porque una tarea no existe. Falla porque una variable de entorno no está configurada. Falla porque tú, como todo el mundo, te equivocaste.

Lo profesional no es fingir que no fallará. Es diseñar qué pasa cuando falle.

Un patrón sencillo:

export class AppError extends Error {
  constructor(
    public statusCode: number,
    message: string
  ) {
    super(message);
  }
}

Uso:

async function getTaskById(id: string) {
  const task = await tasksRepository.findById(id);

  if (!task) {
    throw new AppError(404, 'Tarea no encontrada');
  }

  return task;
}

Y un middleware global:

app.use((error, req, res, next) => {
  if (error instanceof AppError) {
    return res.status(error.statusCode).json({ error: error.message });
  }

  console.error(error);
  return res.status(500).json({ error: 'Error interno del servidor' });
});

El objetivo no es capturar errores por capturarlos. Es evitar respuestas inconsistentes y filtrar información interna.


7. Base de datos: local, Docker, staging o producción

Aquí hay mucha duda razonable: ¿monto la base de datos en mi ordenador? ¿Uso Docker? ¿Mejor un servicio gratuito en la nube? ¿Trabajo directamente contra producción?

La respuesta práctica:

Producción nunca debería ser tu entorno de pruebas.

Trabajar directamente contra producción es cómodo hasta que borras datos reales, rompes una migración o llenas la base de registros basura.

Opción 1: base de datos local

Buena para aprender y empezar rápido.

Ventajas:

  • no dependes de internet
  • es rápida
  • controlas todo
  • puedes romperla sin miedo

Problemas:

  • tu entorno puede no parecerse a producción
  • cuesta compartirlo con otras personas
  • configurar PostgreSQL, MySQL o Mongo local puede ser tedioso

Opción 2: Docker

Para la mayoría de proyectos backend, Docker es una muy buena opción.

services:
  db:
    image: postgres:16
    ports:
      - '5432:5432'
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: tasks

Ventajas:

  • todos usan la misma versión
  • se puede levantar y tirar el entorno fácilmente
  • se parece más a un entorno real
  • no ensucia tanto tu sistema operativo

Problemas:

  • hay que aprender Docker lo justo
  • consume algo más de recursos

Opción 3: servicio gratuito en la nube

Supabase, Neon, MongoDB Atlas, Turso y otros servicios tienen planes gratuitos útiles para prototipos.

Ventajas:

  • te acercas más a un entorno real
  • no instalas base de datos local
  • puedes probar desde varios dispositivos o con otras personas

Problemas:

  • dependes de red y límites del plan gratuito
  • tienes que cuidar credenciales
  • si usas una sola base para todo, puedes mezclar pruebas y datos reales

Entonces, ¿qué haría yo?

Para un CRUD backend:

  1. Desarrollo local con Docker para programar rápido y romper sin miedo.
  2. Base de datos de staging/test en un servicio gratuito para probar despliegue, variables de entorno, latencia y migraciones.
  3. Producción separada solo para datos reales.

La clave no es “local o nube”. La clave es separar entornos:

local      -> mi máquina, datos descartables
staging    -> nube de pruebas, parecido a producción
production -> datos reales, cuidado máximo

Tu ordenador sí puede funcionar perfectamente como entorno de desarrollo. De hecho, debe poder hacerlo. Pero eso no significa que sea el único sitio donde probar antes de publicar.


8. Construir el CRUD por partes

No implementes todo a la vez. Haz una ruta completa de principio a fin y luego repite el patrón.

Orden recomendado:

  1. Conexión a la base de datos
  2. Modelo o tabla tasks
  3. Crear tarea
  4. Listar tareas
  5. Ver tarea por id
  6. Actualizar tarea
  7. Borrar tarea
  8. Validaciones
  9. Errores comunes
  10. Tests

Primero haz que POST /tasks funcione bien. Con validación, error si falta el título y guardado real.

Luego GET /tasks.

Luego GET /tasks/:id.

No saltes entre cinco endpoints a la vez. El progreso real no es tener muchos archivos abiertos. Es tener una parte completa funcionando.


9. Probar: no solo “lo he mirado en Postman”

Postman, Insomnia o Thunder Client están bien para probar manualmente. Pero no bastan.

Pruebas mínimas para el CRUD:

  • crear una tarea válida devuelve 201
  • crear una tarea sin título devuelve 400
  • listar tareas devuelve un array
  • pedir una tarea inexistente devuelve 404
  • actualizar una tarea inexistente devuelve 404
  • borrar una tarea existente devuelve 204 o una respuesta clara

También conviene probar casos feos:

  • ids mal formados
  • payloads demasiado grandes
  • campos extraños
  • base de datos no disponible
  • variables de entorno ausentes

No hace falta convertir cada proyecto pequeño en una catedral de testing. Pero sí necesitas pruebas suficientes para responder:

Si cambio esto mañana, ¿cómo sé que no he roto lo básico?


10. Refactorizar cuando ya existe comportamiento

Refactorizar antes de tener algo que funcione suele ser fantasía arquitectónica.

Primero haz que funcione.
Luego hazlo claro.
Después hazlo más robusto.

Ese orden importa.

Pero “hacer que funcione” no significa escribir basura. Significa aceptar que la primera versión será imperfecta y que la mejorarás con más información.

Un buen refactor suele buscar:

  • nombres más claros
  • funciones más pequeñas
  • menos duplicación real
  • responsabilidades mejor separadas
  • errores más consistentes
  • tests que cubran el comportamiento importante

Clean Code ayuda aquí, pero con una advertencia: no conviertas cada principio en religión. La buena arquitectura no es la que cumple frases bonitas. Es la que hace más fácil cambiar el sistema sin romperlo.


11. Checklist antes de publicar

Antes de desplegar, revisa:

  • .env no está subido al repositorio
  • .env.example sí está actualizado
  • las migraciones de base de datos se ejecutan correctamente
  • producción y staging usan bases de datos separadas
  • los errores internos no se muestran al usuario
  • los endpoints validan entrada
  • no hay claves hardcodeadas
  • hay logs suficientes para diagnosticar fallos
  • el build pasa
  • las pruebas importantes pasan
  • tienes una forma de rollback si algo sale mal

No todo proyecto necesita Kubernetes, CI/CD complejo y observabilidad avanzada.

Pero todo proyecto serio necesita una idea básica de:

  • cómo se configura
  • cómo se prueba
  • cómo se publica
  • cómo se recupera si falla

El método completo

Si tuviera que resumirlo en una receta, sería esta:

  1. Define el resultado esperado.
  2. Reduce el alcance a la primera versión útil.
  3. Diseña una estructura mínima y entendible.
  4. Implementa un flujo completo de principio a fin.
  5. Valida datos desde el primer endpoint.
  6. Controla errores de forma consistente.
  7. Usa local o Docker para desarrollar sin miedo.
  8. Usa staging para probar despliegue en un entorno realista.
  9. Reserva producción para datos reales.
  10. Añade tests sobre lo importante.
  11. Refactoriza con comportamiento ya comprobado.
  12. Publica con checklist, no con fe.

Programar no es solo escribir instrucciones para la máquina. Es tomar decisiones en orden.

La parte difícil no es saber crear una función. Es saber qué función toca crear ahora, qué responsabilidad debe tener, qué puede fallar, cómo lo vas a probar y cuándo la solución ya es suficientemente buena para avanzar.

Eso es construir software con criterio.