From n00b to ZeroCool / Origen

Bases de datos: donde viven los secretos (y cómo no romper MySQL)

Diseña schema y CRUD con MySQL 8, migraciones en SQL, .NET 8 + Dapper y React. Tips reales para no romper production y dormir mejor.

Lo que vale la pena leer aquí

Spoiler: no era chiquito.

Intro con gancho

La primera vez que troné una base en un deploy fue por confiado: ALTER TABLE a mano, viernes 6:40 pm, laptop ya medio cansada y yo jurando que “es un cambio chiquito”.

Spoiler: no era chiquito.

La app quedó a medias, el backend empezó a tirar 500s, y en el Whats de la chamba cayó el clásico: “¿quién tocó production?”. Ahí aprendes rápido que una base de datos no es “otro archivo”, es el lugar donde vive lo que vale: usuarios, pagos, inventario, permisos… y también tus errores.

Si le agarras gusto a dos cosas —schema con orden y migraciones versionadas— te ahorras un montón de talacha, regresos a la oficina (aunque sea virtual), y rollbacks con café del Oxxo.

Qué vas a aprender

  • Diseñar un schema mínimo y útil en MySQL 8 (decisiones reales, no teoría bonita).
  • Versionar cambios con SQL scripts en /db/migrations usando una tabla schema_migrations.
  • CRUD completo con .NET 8 Web API + Dapper usando consultas parametrizadas.
  • Amarrar el flujo con un frontend React (Vite) + Tailwind para listar/crear/editar/borrar.
  • Errores comunes que sí pasan en el jale y cómo salir sin pánico.

Contexto práctico

Vamos a modelar algo que existe en mil productos: un tablero de tareas. No es fancy, pero es perfecto para aterrizar el workflow completo.

  • tasks: id, título, descripción, estado, fechas.
  • Decisiones:
    • id como BIGINT UNSIGNED AUTO_INCREMENT (barato, rápido, muy MySQL-friendly).
    • status como ENUM (sirve para MVP). Si tu producto crece y el cliente pide flujos raros “para ayer”, lo pasas a catálogo.
    • created_at/updated_at para auditoría mínima. Cuando alguien diga “eso ya estaba”, tienes evidencia.

Y lo más importante: migraciones. Porque editar el schema “a manita” funciona… hasta que tu compa clona el repo, corre el backend y su MySQL está en blanco. O cuando staging trae una versión y production otra. Ahí es donde empiezan los bugs fantasma.

Paso a paso: schema + migraciones + CRUD con Dapper

1) Estructura de carpetas para DB

En la raíz del repo (o donde tenga sentido en tu solución), crea:

  • /db/migrations

Ejemplo:

/db
  /migrations
    001_init.sql
    002_add_task_indexes.sql

Regla de oro: un archivo por cambio. Chiquito. Revisable. Fácil de meter en pull request sin miedo.

2) Migración 001: tabla de control + tabla principal

Crea db/migrations/001_init.sql:

-- 001_init.sql

CREATE TABLE IF NOT EXISTS schema_migrations (
  version VARCHAR(50) NOT NULL PRIMARY KEY,
  applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS tasks (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  title VARCHAR(120) NOT NULL,
  description TEXT NULL,
  status ENUM('todo','doing','done') NOT NULL DEFAULT 'todo',
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id)
);

INSERT INTO schema_migrations (version) VALUES ('001_init');

Decisión práctica: guardamos versiones como string (001_init). En PR se entiende de volada qué hizo el cambio. Y te deja poner intención: 003_backfill_status o 010_add_users.

3) Migración 002: índices (porque el proyecto sí crece)

Crea db/migrations/002_add_task_indexes.sql:

-- 002_add_task_indexes.sql

CREATE INDEX idx_tasks_status_created ON tasks (status, created_at);

INSERT INTO schema_migrations (version) VALUES ('002_add_task_indexes');

Tradeoff real: indexar todo “por si acaso” te puede frenar inserts y subir costo. No indexar nada también duele cuando ya hay datos. Aquí indexamos lo típico: filtrar por status y ordenar por created_at.

4) Conexión en .NET 8 (config) + Dapper

En tu .NET 8 Web API, agrega el connection string en appsettings.json:

{
  "ConnectionStrings": {
    "Default": "Server=localhost;Port=3306;Database=zerocool;User=root;Password=tu_password;"
  }
}

En Program.cs, registra lo mínimo para MySQL con Dapper (usando MySqlConnector):

using MySqlConnector;
using System.Data;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddScoped<IDbConnection>(_ =>
    new MySqlConnection(builder.Configuration.GetConnectionString("Default")));

var app = builder.Build();

app.MapControllers();
app.Run();

Decisión de jale: inyectar IDbConnection es simple y funciona. Ya si tu app se pone seria, te armas un factory o controlas mejor el scope, pero no te detengas por “arquitectura perfecta”.

5) Modelo + DTOs (lo que entra y sale)

Modelo simple:

public class TaskItem
{
    public ulong Id { get; set; }
    public string Title { get; set; } = "";
    public string? Description { get; set; }
    public string Status { get; set; } = "todo";
    public DateTime Created_At { get; set; }
    public DateTime Updated_At { get; set; }
}

public record CreateTaskDto(string Title, string? Description);
public record UpdateTaskDto(string Title, string? Description, string Status);

Nota de guerra: MySQL trae created_at y updated_at. Dapper mapea por nombre; para evitar fricción, yo prefiero alias en SQL y mantener C# con nombres consistentes.

6) Repositorio con Dapper (parametrizado, sin inventos)

Crea TaskRepository:

using Dapper;
using System.Data;

public class TaskRepository
{
    private readonly IDbConnection _db;

    public TaskRepository(IDbConnection db) => _db = db;

    public async Task<IEnumerable<TaskItem>> GetAllAsync()
    {
        const string sql = @"
            SELECT 
                id AS Id,
                title AS Title,
                description AS Description,
                status AS Status,
                created_at AS CreatedAt,
                updated_at AS UpdatedAt
            FROM tasks
            ORDER BY created_at DESC;";

        return await _db.QueryAsync<TaskItem>(sql);
    }

    public async Task<TaskItem?> GetByIdAsync(ulong id)
    {
        const string sql = @"
            SELECT 
                id AS Id,
                title AS Title,
                description AS Description,
                status AS Status,
                created_at AS CreatedAt,
                updated_at AS UpdatedAt
            FROM tasks
            WHERE id = @Id;";

        return await _db.QueryFirstOrDefaultAsync<TaskItem>(sql, new { Id = id });
    }

    public async Task<ulong> CreateAsync(CreateTaskDto dto)
    {
        const string sql = @"
            INSERT INTO tasks (title, description)
            VALUES (@Title, @Description);
            SELECT LAST_INSERT_ID();";

        return await _db.ExecuteScalarAsync<ulong>(sql, new { dto.Title, dto.Description });
    }

    public async Task<bool> UpdateAsync(ulong id, UpdateTaskDto dto)
    {
        const string sql = @"
            UPDATE tasks
            SET title = @Title,
                description = @Description,
                status = @Status
            WHERE id = @Id;";

        var rows = await _db.ExecuteAsync(sql, new { Id = id, dto.Title, dto.Description, dto.Status });
        return rows == 1;
    }

    public async Task<bool> DeleteAsync(ulong id)
    {
        const string sql = "DELETE FROM tasks WHERE id = @Id;";
        var rows = await _db.ExecuteAsync(sql, new { Id = id });
        return rows == 1;
    }
}

Tres cosas que sí importan:

  1. Parametrizado (@Id, @Title): baja riesgo de SQL injection y evita bugs con comillas.
  2. LAST_INSERT_ID() es tu compa para regresar el id recién creado.
  3. Si Update/Delete afecta 0 rows, no vendas humo: devuelve false y decide qué responder.

7) Controller (API limpia y predecible)

Registra el repo:

builder.Services.AddScoped<TaskRepository>();

Y crea TasksController:

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/tasks")]
public class TasksController : ControllerBase
{
    private readonly TaskRepository _repo;

    public TasksController(TaskRepository repo) => _repo = repo;

    [HttpGet]
    public async Task<IActionResult> GetAll()
        => Ok(await _repo.GetAllAsync());

    [HttpGet("{id:ulong}")]
    public async Task<IActionResult> GetById(ulong id)
    {
        var item = await _repo.GetByIdAsync(id);
        return item is null ? NotFound() : Ok(item);
    }

    [HttpPost]
    public async Task<IActionResult> Create([FromBody] CreateTaskDto dto)
    {
        if (string.IsNullOrWhiteSpace(dto.Title))
            return BadRequest(new { error = "Title es requerido" });

        var id = await _repo.CreateAsync(dto);
        var created = await _repo.GetByIdAsync(id);
        return Created($"/api/tasks/{id}", created);
    }

    [HttpPut("{id:ulong}")]
    public async Task<IActionResult> Update(ulong id, [FromBody] UpdateTaskDto dto)
    {
        var allowed = new[] { "todo", "doing", "done" };
        if (!allowed.Contains(dto.Status))
            return BadRequest(new { error = "Status inválido" });

        var ok = await _repo.UpdateAsync(id, dto);
        return ok ? NoContent() : NotFound();
    }

    [HttpDelete("{id:ulong}")]
    public async Task<IActionResult> Delete(ulong id)
    {
        var ok = await _repo.DeleteAsync(id);
        return ok ? NoContent() : NotFound();
    }
}

Decisión pragmática: validación mínima en API. No es “la forma más pura”, pero cuando traes deadline y el negocio solo quiere que funcione, esto te da errores claros y te evita idas y vueltas.

Bases de datos: donde viven los secretos (y cómo no romper MySQL) - visual explicativa 1
Visual de apoyo: Intro con gancho

8) Frontend: React + Vite + Tailwind (lo suficiente para vivir)

No se trata de armar un monstruo. Solo un panel que:

  • lista tareas
  • crea tarea
  • rota status
  • borra

Un TaskBoard.jsx rápido:

import { useEffect, useState } from "react";

const API = "/api/tasks";

export default function TaskBoard() {
  const [items, setItems] = useState([]);
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [error, setError] = useState(null);

  async function load() {
    setError(null);
    const res = await fetch(API);
    if (!res.ok) return setError("No pude cargar tareas");
    setItems(await res.json());
  }

  useEffect(() => { load(); }, []);

  async function createTask(e) {
    e.preventDefault();
    setError(null);

    const res = await fetch(API, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title, description: description || null })
    });

    if (!res.ok) {
      const payload = await res.json().catch(() => ({}));
      return setError(payload.error || "No se pudo crear");
    }

    setTitle("");
    setDescription("");
    await load();
  }

  async function setStatus(id, current) {
    const next = current === "todo" ? "doing" : current === "doing" ? "done" : "todo";

    const item = items.find(x => x.id === id);
    const res = await fetch(`${API}/${id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        title: item.title,
        description: item.description,
        status: next
      })
    });

    if (!res.ok) return setError("No se pudo actualizar");
    await load();
  }

  async function remove(id) {
    const res = await fetch(`${API}/${id}`, { method: "DELETE" });
    if (!res.ok) return setError("No se pudo borrar");
    await load();
  }

  return (
    <div className="max-w-3xl mx-auto p-6">
      <h1 className="text-2xl font-bold">Tasks</h1>

      <form onSubmit={createTask} className="mt-4 grid gap-2">
        <input
          className="border rounded px-3 py-2"
          placeholder="Título"
          value={title}
          onChange={e => setTitle(e.target.value)}
        />
        <textarea
          className="border rounded px-3 py-2"
          placeholder="Descripción (opcional)"
          value={description}
          onChange={e => setDescription(e.target.value)}
        />
        <button className="bg-black text-white rounded px-3 py-2 w-fit">
          Crear
        </button>
        {error && <p className="text-red-600">{error}</p>}
      </form>

      <ul className="mt-6 grid gap-2">
        {items.map(t => (
          <li key={t.id} className="border rounded p-3 flex items-start justify-between gap-4">
            <div>
              <p className="font-semibold">{t.title}</p>
              {t.description && <p className="text-sm text-gray-700">{t.description}</p>}
              <p className="text-xs mt-1">status: <span className="font-mono">{t.status}</span></p>
            </div>
            <div className="flex gap-2">
              <button
                onClick={() => setStatus(t.id, t.status)}
                className="border rounded px-2 py-1"
              >
                Rotar status
              </button>
              <button
                onClick={() => remove(t.id)}
                className="border rounded px-2 py-1 text-red-700"
              >
                Borrar
              </button>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

Realidad de jale: el PUT manda el objeto completo porque es lo más rápido para salir del paso sin inventar “PATCH”. Ya con el producto estable, decides si vale la pena afinar el contrato.

Screenshots sugeridos

  • Terminal mostrando ejecución de 001_init.sql y 002_add_task_indexes.sql en MySQL.
  • Tabla schema_migrations con las versiones aplicadas.
  • Respuesta de GET /api/tasks en el navegador o cliente REST.
  • UI en React listando tareas y el cambio de status.
  • Error visible cuando mandas status inválido y la API responde 400.

Errores comunes + solución

1) “En mi máquina jala” porque tu DB no tiene el mismo schema

Síntoma: a alguien le truena con “Unknown column” o “Table doesn't exist”.

Arreglo: disciplina de migraciones.

  • Todo cambio va a /db/migrations/00x_*.sql.
  • En PR, checklist real: “corrí migraciones desde cero en una DB limpia”. Si no, es deuda técnica disfrazada.

2) Guardar strings en SQL concatenando

Síntoma: funciona con un título normal, truena con comillas o alguien mete texto malicioso.

Arreglo: Dapper parametrizado siempre.

  • WHERE id = @Id
  • VALUES (@Title, @Description)

3) ENUM y el frontend mandando cualquier cosa

Síntoma: MySQL rechaza el update o lo convierte a un valor raro.

Arreglo: valida en API y amarra el UI a un set cerrado.

  • En el controller ya metimos allowed.
  • En frontend, si ya conoces los estados, mejor usa select/botones con opciones fijas.

4) Índices metidos “porque sí”

Síntoma: inserts más lentos, disco y CPU extra, nadie sabe por qué.

Arreglo: indexa por consulta real.

  • Si filtras por status, indexa status.
  • Si filtras y ordenas por created_at, índice compuesto como hicimos.

5) Migraciones re-ejecutadas o corridas fuera de orden

Síntoma: “Duplicate key name”, “table already exists” o peor: schema a medias.

Arreglo: schema_migrations es tu bitácora.

  • Antes de aplicar un script, verifica que su version no exista.
  • Si aplicas manualmente, registra la versión. Si no, luego te disparas en el pie cuando alguien haga setup desde cero.
Bases de datos: donde viven los secretos (y cómo no romper MySQL) - visual explicativa 2
Visual de apoyo: Qué vas a aprender

Checklist final

  • Tengo /db/migrations con scripts numerados y chicos.
  • Existe schema_migrations y registra cada cambio aplicado.
  • MySQL 8 corre con el schema correcto (ideal: DB limpia + migraciones desde cero).
  • El API en .NET 8 usa Dapper con parámetros (nada de concatenar strings).
  • CRUD completo: GET all, GET by id, POST, PUT, DELETE.
  • Validación mínima en API (title requerido, status permitido).
  • Frontend React (Vite) consume endpoints y maneja errores sin “silencio”.
  • Agregué al menos un índice con una razón (query real), no por reflejo.

FAQ

1) ¿Por qué versionar SQL si “solo somos dos devs”?

Porque cuando son pocos, cada hora perdida cuesta más. Y acá es bien común que el proyecto viva repartido entre laptops, home office, coworkings y redes medio gachas. Poder reconstruir la DB sin pedirle favores a nadie te salva el sprint.

2) ¿ENUM en MySQL es buena idea?

Para MVP sí: rápido, claro, barato. Cuando necesites estados dinámicos, roles configurables o flujos por cliente, lo migras a catálogo. Lo importante es que API y UI no sean un “free for all”.

3) ¿Dapper me obliga a escribir mucho SQL?

Sí, ese es el trato. A cambio tienes control y performance predecible. Si vienes de puro ORM, al principio te duele… pero también aprendes a pensar en datos de neta.

4) ¿Cómo evito que alguien corra migraciones a medias en producción?

Proceso y hábito:

  • scripts chicos,
  • probar en staging,
  • saber qué versión corre en prod (schema_migrations).
    Si algo sale mal, tener el registro exacto te evita el drama de “¿quién tocó qué?”.

5) ¿Qué hago si necesito cambios destructivos (drop/rename) sin perder datos?

Primero migración suave: agregas columna nueva, haces backfill, actualizas código, y después migración de limpieza. Es más lento, sí, pero es el precio de no perder información (y de no andar pidiendo un backup a las 2 am).

Siguiente episodio: teaser

Ya que los datos tienen casa, toca poner reglas de acceso: quién puede ver qué y cómo autenticar sin que te roben la sesión.

Seguimos con el mismo stack para que tu app ya huela a producto real, no a demo de curso a medias.