From n00b to ZeroCool / Origen

Backend para mortales: lo que sí necesitas entender (aterrizado con .NET 8)

Entiende backend sin humo: API, DB, JWT y deploy básico. Ejemplo con .NET 8 Web API + Dapper + MySQL y scripts SQL versionados.

Lo que vale la pena leer aquí

Y todavía falta el comentario en la daily: “¿y por qué no lo hicimos en Node/Python?” como si el bug se arreglara por religión.

Intro con gancho

Son las 11:47 pm y te cae el mensaje que nadie quiere: “Oye, el login ya no sirve y mañana lo enseñamos al cliente”. Tú jurabas que “el backend es nomás una API”, pero ahí estás: Postman abierto, un 401 que no explica nada, MySQL con Access denied y tu laptop viejita sonando como microondas.

Y todavía falta el comentario en la daily: “¿y por qué no lo hicimos en Node/Python?” como si el bug se arreglara por religión.

La neta: backend no es un lenguaje. Es un set de responsabilidades. Si entiendes las piezas, te puedes mover entre Node, .NET, Python y compañía sin sentir que te cambiaron el piso cada sprint.

Qué te vas a llevar

  • Qué hace un backend en español normal (sin poema, sin humo).
  • Cómo se ve un backend “de verdad” en una app: API + DB + auth + logs + deploy.
  • Un workflow práctico con el stack del curso: .NET 8 Web API + Dapper + MySQL + scripts SQL versionados.
  • Decisiones con colmillo: qué sí dejar listo para production, qué no, y por qué.

Contexto práctico

Cuando alguien dice “hay que hacer backend”, normalmente te está pidiendo una o varias de estas talachas:

  1. Recibir requests (HTTP) y responder con algo útil (JSON).
  2. Aplicar reglas de negocio: permisos, límites, estados, validaciones.
  3. Hablar con la base de datos sin romperla: queries, transacciones, performance.
  4. Autenticación y autorización: quién eres (auth) y qué puedes hacer (roles/claims).
  5. Seguridad: input bien tratado, nada de secretos expuestos, no filtrar datos.
  6. Operación: logs, métricas, manejo de errores, deploy y rollback.

El lenguaje cambia sintaxis y tooling, pero el trabajo es el mismo. Y en el mundo real pasan cosas bien específicas:

  • Tu backend vive detrás de un reverse proxy (Nginx/Cloudflare) y “mágicamente” te cambia headers.
  • La DB está en un server que “solo abre el puerto a ciertas IPs” y tú estás tethering del cel porque el internet decidió morir.
  • El token expira justo cuando arranca el demo y el PM te ve con cara de “¿por qué tardas?”.

Vamos a aterrizarlo con una mini-API: auth con JWT + endpoint protegido + acceso a MySQL con Dapper.

Guía principal (sin humo)

1) Define el contrato de tu API antes de codear como loco

Si no lo defines, acabas con endpoints improvisados tipo /api/doThing y nadie sabe qué manda ni qué regresa.

Ejemplo mínimo para una app típica:

  • POST /api/auth/login → regresa access_token (JWT)
  • GET /api/profile/me → requiere token, regresa datos del usuario

Decisiones que te ahorran bugs:

  • ¿Tu JWT va en Authorization: Bearer ...? (sí)
  • ¿Qué claims metes? sub (userId), email, role.
  • ¿Cuánto dura el token? Para demo: 60 min. Para production: depende del riesgo y UX.

2) Crea el proyecto .NET 8 Web API

En tu repo, carpeta backend/:

dotnet new webapi -n ByteIt.Api
cd ByteIt.Api

Tip de sobrevivencia: borra el WeatherForecast si no lo vas a usar. Menos ruido, menos confusión cuando estés con prisa.

3) Agrega Dapper y MySQL connector

dotnet add package Dapper
dotnet add package MySqlConnector

Decisión real: Dapper no te “esconde” SQL. Eso está chido cuando:

  • quieres controlar queries,
  • necesitas performance,
  • tu DB ya tiene vida propia.

Pero también exige disciplina: SQL legible, parámetros siempre, y scripts versionados. Si no, el primer freelance mal cotizado que entre te deja una bomba de tiempo.

4) Versiona scripts SQL (sin herramienta fancy, puro orden)

Estructura sugerida:

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

Ejemplo 001_init.sql:

CREATE TABLE users (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  email VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  role VARCHAR(50) NOT NULL DEFAULT 'user',
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Ejemplo 002_seed.sql (para desarrollo local):

INSERT INTO users(email, password_hash, role)
VALUES ('demo@byteit.mx', '$2a$11$REEMPLAZA_POR_UN_HASH_REAL', 'admin');

Regla simple: aunque sea seed, no subas contraseñas reales. Lo “privado” se filtra por screenshot, por repo mal configurado o por alguien que le da merge con prisa.

5) Configura connection string y settings

En appsettings.Development.json:

{
  "ConnectionStrings": {
    "Default": "Server=localhost;Port=3306;Database=byteit;User ID=root;Password=tu_password;"
  },
  "Jwt": {
    "Issuer": "byteit",
    "Audience": "byteit",
    "Key": "CAMBIA_ESTO_POR_UN_SECRETO_LARGO"
  }
}

Tradeoff honesto:

  • En dev, va.
  • En production, esto debe venir por variables de entorno. Si no, un día alguien copia/pega configs en un ticket y ya valió.

6) Repo con Dapper (acceso a datos sin marranadas)

Un patrón simple: Repositories/UserRepository.cs.

using Dapper;
using MySqlConnector;

public sealed class UserRepository
{
    private readonly string _cs;
    public UserRepository(IConfiguration config)
        => _cs = config.GetConnectionString("Default")!;

    public async Task<User?> GetByEmailAsync(string email)
    {
        const string sql = @"
            SELECT id, email, password_hash AS PasswordHash, role
            FROM users
            WHERE email = @email
            LIMIT 1;";

        await using var conn = new MySqlConnection(_cs);
        return await conn.QuerySingleOrDefaultAsync<User>(sql, new { email });
    }

    public async Task<User?> GetByIdAsync(long id)
    {
        const string sql = @"
            SELECT id, email, role
            FROM users
            WHERE id = @id
            LIMIT 1;";

        await using var conn = new MySqlConnection(_cs);
        return await conn.QuerySingleOrDefaultAsync<User>(sql, new { id });
    }
}

public sealed class User
{
    public long Id { get; set; }
    public string Email { get; set; } = "";
    public string? PasswordHash { get; set; }
    public string Role { get; set; } = "user";
}

Detalle que evita un susto: en GetByIdAsync no regresamos PasswordHash. En backend, el default debería ser: no expongas lo que no necesitas. Menos superficie, menos leak.

7) Auth con JWT (lo mínimo que sí jala)

Instala paquetes:

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.IdentityModel.Tokens

Configura en Program.cs:

using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddSingleton<UserRepository>();

var jwtSection = builder.Configuration.GetSection("Jwt");
var key = jwtSection["Key"]!;

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwtSection["Issuer"],
            ValidAudience = jwtSection["Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
            ClockSkew = TimeSpan.FromSeconds(30)
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();

app.UseHttpsRedirection();

app.UseAuthentication();
app.UseAuthorization();

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

Y el controlador Controllers/AuthController.cs:

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly UserRepository _users;
    private readonly IConfiguration _config;

    public AuthController(UserRepository users, IConfiguration config)
    {
        _users = users;
        _config = config;
    }

    public sealed record LoginRequest(string Email, string Password);

    [HttpPost("login")]
    public async Task<IActionResult> Login([FromBody] LoginRequest req)
    {
        if (string.IsNullOrWhiteSpace(req.Email) || string.IsNullOrWhiteSpace(req.Password))
            return BadRequest(new { message = "Email y password requeridos" });

        var user = await _users.GetByEmailAsync(req.Email);
        if (user is null)
            return Unauthorized(new { message = "Credenciales inválidas" });

        // En vida real: verifica hash (BCrypt/Argon2). Aquí dejamos el lugar.
        // if (!BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) return Unauthorized(...);

        // Para probar el flujo sin meter más dependencias:
        if (user.PasswordHash is null || req.Password != "demo")
            return Unauthorized(new { message = "Credenciales inválidas" });

        var jwt = _config.GetSection("Jwt");
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt["Key"]!));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new(JwtRegisteredClaimNames.Email, user.Email),
            new("role", user.Role)
        };

        var token = new JwtSecurityToken(
            issuer: jwt["Issuer"],
            audience: jwt["Audience"],
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(60),
            signingCredentials: creds);

        var accessToken = new JwtSecurityTokenHandler().WriteToken(token);

        return Ok(new { access_token = accessToken });
    }
}

Dos notas sin maquillaje:

  • Ese req.Password != "demo" es un hack de demo. En chamba real: hash con BCrypt/Argon2, siempre.
  • Si tu backend va a internet, considera rate limiting y lockouts. Si no, el login se vuelve target desde el día uno.

8) Endpoint protegido (para que haga “click”)

Controllers/ProfileController.cs:

using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/profile")]
public class ProfileController : ControllerBase
{
    private readonly UserRepository _users;
    public ProfileController(UserRepository users) => _users = users;

    [Authorize]
    [HttpGet("me")]
    public async Task<IActionResult> Me()
    {
        var sub = User.FindFirstValue(ClaimTypes.NameIdentifier)
                  ?? User.FindFirstValue(JwtRegisteredClaimNames.Sub);

        if (string.IsNullOrWhiteSpace(sub) || !long.TryParse(sub, out var userId))
            return Unauthorized(new { message = "Token inválido" });

        var user = await _users.GetByIdAsync(userId);
        if (user is null)
            return NotFound(new { message = "Usuario no existe" });

        return Ok(new { user.Id, user.Email, user.Role });
    }
}

9) Prueba rápida con curl o Postman

Login:

curl -X POST http://localhost:5000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"demo@byteit.mx","password":"demo"}'

Luego usa el token:

curl http://localhost:5000/api/profile/me \
  -H "Authorization: Bearer TU_TOKEN" \
  -H "Accept: application/json"

Si responde tu usuario, ya cerraste el circuito completo: request → auth → DB → respuesta. Ese es el backend básico que sí se puede defender.

Backend para mortales: lo que sí necesitas entender (aterrizado con .NET 8) - visual explicativa 1
Visual de apoyo: Intro con gancho

10) Deploy: lo mínimo para no sufrir (y poder hacer rollback)

Deploy es donde la banda se confía y se rompe todo por razones bien tontas:

  • variable de entorno mal escrita,
  • el server no tiene el puerto abierto,
  • el proxy no reenvía Authorization,
  • se te olvidó correr migraciones.

Guía corta (sin casarte con proveedor):

  1. Build release
dotnet publish -c Release -o ./publish
  1. Variables de entorno en production
  • ConnectionStrings__Default
  • Jwt__Key
  • Jwt__Issuer
  • Jwt__Audience

En .NET, el doble guion bajo __ mapea a secciones.

  1. Health check básico
    Agrega GET /health que responda 200, para que tu infra (o tu monitor) sepa si está vivo.

  2. Plan de rollback

  • Tu artefacto publicado debe tener versión.
  • Tus scripts SQL deben ser idempotentes o migrables.
  • Si una migración es destructiva, se anuncia y se planea. No “a ver si pasa” a las 6:55 pm.

Este punto te salva cuando el deploy cae tarde, hay deadline y el negocio nomás necesita que algo funcione.

Screenshots sugeridos

  • Swagger UI mostrando POST /api/auth/login y GET /api/profile/me con candado de Authorize.
  • Postman: request de login y respuesta con access_token.
  • Postman: request a /api/profile/me con header Authorization: Bearer ....
  • Terminal: dotnet publish y estructura de carpeta /publish.
  • MySQL: tabla users y una consulta SELECT id,email,role FROM users;.

Errores comunes + solución

1) 401 aunque mandas token

Causa típica: app.UseAuthentication() está después de UseAuthorization().

Arreglo:

app.UseAuthentication();
app.UseAuthorization();

2) Access denied for user a MySQL

Causa típica: connection string mal (usuario/pass), o tu usuario no tiene permisos al schema.

Arreglo:

  • Verifica Server/Port/Database/User ID/Password.
  • Prueba conexión desde tu máquina (DBeaver/MySQL CLI).
  • Asegura permisos: GRANT al usuario correcto.

3) Tu endpoint regresa 500 y no sabes por qué

Causa típica: excepción en query/connection y en production no ves stacktrace.

Arreglo:

  • Loggea el error (mínimo ILogger).
  • Regresa ProblemDetails cuando aplique.
  • Confirma que la tabla existe (sí pasa: alguien no corrió 001_init.sql).

4) Token “válido” pero no pasan los claims

Causa típica: estás leyendo el claim equivocado.

Arreglo:

  • El user id estándar suele ser sub.
  • Lee JwtRegisteredClaimNames.Sub o mapea NameIdentifier explícitamente.

5) Funciona local, muere en deploy por HTTPS/Proxy

Causa típica: el reverse proxy cambia esquema/headers o se come Authorization.

Arreglo:

  • Asegura que el proxy reenvíe Authorization.
  • Configura forwarded headers si aplica.
  • Valida CORS si tu frontend (React, etc.) vive en otro dominio.
Backend para mortales: lo que sí necesitas entender (aterrizado con .NET 8) - visual explicativa 2
Visual de apoyo: Qué te vas a llevar

Checklist final

  • Endpoints claros (/auth/login, /profile/me).
  • Validación de input y nada de datos sensibles en respuestas.
  • JWT configurado con issuer/audience/key y expiración razonable.
  • UseAuthentication() y UseAuthorization() en orden correcto.
  • MySQL con Dapper usando parámetros (nada de concatenar strings).
  • Scripts SQL en /db/migrations versionados.
  • Variables de entorno listas para production (cero secretos en repo).
  • dotnet publish funcionando y plan de rollback real.

FAQ

1) Entonces… ¿backend es la API y ya?

No. La API es la cara visible. Backend también es reglas de negocio, seguridad, manejo de errores, conexión a DB, observabilidad y operación (deploy/rollback).

2) ¿Por qué Dapper y no algo que haga todo automático?

Porque aquí buscamos control y claridad: SQL explícito, performance decente y menos magia. La magia está chida… hasta que truena en production y nadie sabe qué query se ejecutó.

3) ¿JWT es “seguro” para login?

JWT bien configurado es útil. Lo inseguro es: llaves débiles, expiraciones eternas, no usar HTTPS, filtrar tokens en logs, o guardar tokens en lugares chafas del frontend.

4) ¿Cuánto tiempo debe durar un token?

Depende. Para apps internas a veces 8–12 horas. Para apps públicas, comúnmente 15–60 minutos y refresh tokens. Arranca con 60 para dev/demo y ajusta con base en riesgo y UX.

5) ¿Qué monitoreo primero en un backend pequeño?

Errores (5xx), latencia por endpoint, tasa de 401/403 (auth rota) y salud de DB (conexiones, timeouts). Con eso cachas el 80% de incendios antes del “ya se cayó”.

Siguiente episodio: teaser

Ya que tu backend respira, toca hacerlo convivir con el frontend sin dramas: CORS, cookies/tokens, y el flujo completo React → API → DB.

Y sí: el día que te funcione en tu máquina pero no en production… ese día te ganas el título de fullstack a la mala.