Módulo 18: ADO.NET

Objetivos del módulo

  • Comprender qué es ADO.NET y cuándo usarlo
  • Conectarse directamente a una base de datos desde C# 14 con .NET 10
  • Ejecutar consultas SQL con DbConnection, DbCommand y parámetros
  • Leer datos con DbDataReader
  • Implementar un CRUD completo usando SQL directo
  • Usar transacciones, consultas asíncronas y manejo correcto de errores
  • Conocer propiedades avanzadas: pooling, timeout, aislamiento, CommandType, DataTable y proveedores

1. ¿Qué es ADO.NET?

📘 Concepto: ADO.NET es el conjunto de clases de .NET para comunicarse directamente con bases de datos. Permite abrir conexiones, ejecutar SQL, leer resultados y controlar transacciones sin usar un ORM como Entity Framework Core.

Entity Framework Core trabaja por encima de ADO.NET. Cuando EF Core guarda o consulta datos, por debajo termina usando conexiones, comandos y proveedores de datos.

Arquitectura básica de ADO.NET ADO.NET se sitúa entre tu código C# y el proveedor específico de la base de datos

Concepto Significado
DbConnection Conexión con la base de datos
DbCommand Comando SQL que se ejecuta contra la conexión
DbParameter Valor seguro que se envía a una consulta SQL
DbDataReader Lector rápido, hacia delante, para recorrer filas
DataTable Tabla en memoria, útil para datos desconectados
Transacción Bloque de operaciones que se confirman o cancelan juntas
Proveedor Librería concreta para SQLite, SQL Server, PostgreSQL, MySQL, etc.

¿Cuándo usar ADO.NET?

Usa ADO.NET cuando… Usa EF Core cuando…
Necesitas control total del SQL Quieres trabajar con clases y LINQ
Vas a ejecutar consultas muy específicas Quieres productividad y migraciones
Tienes procedimientos almacenados complejos Quieres mapear relaciones entre entidades
Estás optimizando rendimiento fino La lógica encaja bien con un modelo de dominio
Mantienes una base de datos heredada Quieres reducir código repetitivo

💡 Consejo: ADO.NET no reemplaza a EF Core. Es una herramienta más cercana al metal. En proyectos reales pueden convivir: EF Core para CRUD habitual y ADO.NET para consultas críticas o muy específicas.


2. Instalar paquetes

ADO.NET define las clases base en .NET, pero necesitas un proveedor para la base de datos concreta.

Para este módulo usaremos SQLite porque crea una base de datos en un archivo y es fácil de probar:

dotnet new console -n TiendaAdoNet
cd TiendaAdoNet
dotnet add package Microsoft.Data.Sqlite

Si quisieras usar SQL Server:

dotnet add package Microsoft.Data.SqlClient

Ejemplo de referencias en el .csproj:

<ItemGroup>
  <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" />
  <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.1" />
</ItemGroup>

⚠️ Importante: No necesitas instalar los dos proveedores a la vez. Instala solo el proveedor de la base de datos que vayas a usar.

Instalar desde Visual Studio Community

Puedes instalar el proveedor desde Herramientas → Administrador de paquetes NuGet → Administrar paquetes NuGet para la solución….

Busca:

  • Microsoft.Data.Sqlite para SQLite
  • Microsoft.Data.SqlClient para SQL Server

Selecciona el proyecto correcto y pulsa Instalar.

Instalar desde Visual Studio Code

En VS Code abre la terminal integrada en la carpeta donde está el .csproj:

dotnet add package Microsoft.Data.Sqlite

3. Crear la base de datos

Empezaremos con una tabla Productos en SQLite.

using Microsoft.Data.Sqlite;

const string connectionString = "Data Source=tienda.db";

await using SqliteConnection connection = new(connectionString);
await connection.OpenAsync();

string sql = """
CREATE TABLE IF NOT EXISTS Productos (
    Id INTEGER PRIMARY KEY AUTOINCREMENT,
    Nombre TEXT NOT NULL,
    Categoria TEXT NOT NULL,
    Precio REAL NOT NULL,
    Stock INTEGER NOT NULL,
    FechaCreacion TEXT NOT NULL
);
""";

await using SqliteCommand command = connection.CreateCommand();
command.CommandText = sql;

await command.ExecuteNonQueryAsync();

Console.WriteLine("Base de datos preparada.");
Parte Explicación
SqliteConnection Conexión específica para SQLite
OpenAsync() Abre la conexión
CreateCommand() Crea un comando asociado a la conexión
CommandText SQL que se va a ejecutar
ExecuteNonQueryAsync() Ejecuta SQL que no devuelve filas

💡 Consejo: Usa await using con conexiones y comandos. Así se cierran correctamente aunque ocurra una excepción.


4. Tres formas de ejecutar comandos

ADO.NET tiene tres métodos principales para ejecutar SQL:

Método Devuelve Uso típico
ExecuteNonQuery / ExecuteNonQueryAsync Número de filas afectadas INSERT, UPDATE, DELETE, CREATE TABLE
ExecuteScalar / ExecuteScalarAsync Un único valor COUNT, MAX, nuevo Id
ExecuteReader / ExecuteReaderAsync Filas completas SELECT

ExecuteNonQueryAsync

await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE Productos
SET Stock = Stock + 10
WHERE Id = @id;
""";

command.Parameters.AddWithValue("@id", 1);

int filasAfectadas = await command.ExecuteNonQueryAsync();
Console.WriteLine($"Filas modificadas: {filasAfectadas}");

ExecuteScalarAsync

await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT COUNT(*) FROM Productos;";

long total = (long)(await command.ExecuteScalarAsync() ?? 0);
Console.WriteLine($"Total de productos: {total}");

ExecuteReaderAsync

await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
SELECT Id, Nombre, Precio
FROM Productos
ORDER BY Nombre;
""";

await using SqliteDataReader reader = await command.ExecuteReaderAsync();

while (await reader.ReadAsync())
{
    int id = reader.GetInt32(0);
    string nombre = reader.GetString(1);
    double precio = reader.GetDouble(2);

    Console.WriteLine($"{id}: {nombre} - {precio:C2}");
}

5. Parámetros y seguridad

⚠️ Importante: Nunca construyas SQL concatenando texto del usuario. Usa parámetros para evitar inyección SQL y errores de formato.

Mal:

string nombre = Console.ReadLine()!;
command.CommandText = $"SELECT * FROM Productos WHERE Nombre = '{nombre}'";

Bien:

string nombre = Console.ReadLine()!;

await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
SELECT Id, Nombre, Precio
FROM Productos
WHERE Nombre = @nombre;
""";

command.Parameters.AddWithValue("@nombre", nombre);

Ejemplo de ataque si concatenas:

' OR 1=1 --

Con parámetros, ese texto se interpreta como un valor normal, no como parte del SQL.

Parámetros con tipo

AddWithValue es cómodo para ejemplos, pero en código profesional conviene declarar tipos cuando necesitas precisión:

SqliteParameter precio = command.CreateParameter();
precio.ParameterName = "@precio";
precio.SqliteType = SqliteType.Real;
precio.Value = 89.99;

command.Parameters.Add(precio);

6. Modelo de datos

Usaremos este modelo para el CRUD:

public class Producto
{
    public int Id { get; set; }
    public string Nombre { get; set; } = "";
    public string Categoria { get; set; } = "";
    public decimal Precio { get; set; }
    public int Stock { get; set; }
    public DateTime FechaCreacion { get; set; }

    public override string ToString()
    {
        return $"{Id}: {Nombre} ({Categoria}) - {Precio:C2} - Stock: {Stock}";
    }
}

Y una clase repositorio para concentrar el acceso a datos:

public class ProductoRepository
{
    private readonly string _connectionString;

    public ProductoRepository(string connectionString)
    {
        _connectionString = connectionString;
    }

    private SqliteConnection CrearConexion()
    {
        return new SqliteConnection(_connectionString);
    }
}

7. CRUD completo con ADO.NET

Flujo CRUD con ADO.NET Las operaciones CRUD se ejecutan con SQL parametrizado

Crear la tabla

public async Task InicializarAsync()
{
    await using SqliteConnection connection = CrearConexion();
    await connection.OpenAsync();

    await using SqliteCommand command = connection.CreateCommand();
    command.CommandText = """
    CREATE TABLE IF NOT EXISTS Productos (
        Id INTEGER PRIMARY KEY AUTOINCREMENT,
        Nombre TEXT NOT NULL,
        Categoria TEXT NOT NULL,
        Precio REAL NOT NULL,
        Stock INTEGER NOT NULL,
        FechaCreacion TEXT NOT NULL
    );
    """;

    await command.ExecuteNonQueryAsync();
}

Create: insertar producto

public async Task<int> CrearAsync(Producto producto)
{
    await using SqliteConnection connection = CrearConexion();
    await connection.OpenAsync();

    await using SqliteCommand command = connection.CreateCommand();
    command.CommandText = """
    INSERT INTO Productos (Nombre, Categoria, Precio, Stock, FechaCreacion)
    VALUES (@nombre, @categoria, @precio, @stock, @fechaCreacion);

    SELECT last_insert_rowid();
    """;

    command.Parameters.AddWithValue("@nombre", producto.Nombre);
    command.Parameters.AddWithValue("@categoria", producto.Categoria);
    command.Parameters.AddWithValue("@precio", producto.Precio);
    command.Parameters.AddWithValue("@stock", producto.Stock);
    command.Parameters.AddWithValue("@fechaCreacion", producto.FechaCreacion.ToString("O"));

    long id = (long)(await command.ExecuteScalarAsync() ?? 0);
    return (int)id;
}

Read: obtener todos

public async Task<List<Producto>> ObtenerTodosAsync()
{
    List<Producto> productos = new();

    await using SqliteConnection connection = CrearConexion();
    await connection.OpenAsync();

    await using SqliteCommand command = connection.CreateCommand();
    command.CommandText = """
    SELECT Id, Nombre, Categoria, Precio, Stock, FechaCreacion
    FROM Productos
    ORDER BY Nombre;
    """;

    await using SqliteDataReader reader = await command.ExecuteReaderAsync();

    while (await reader.ReadAsync())
    {
        productos.Add(MapearProducto(reader));
    }

    return productos;
}

Read: obtener por Id

public async Task<Producto?> ObtenerPorIdAsync(int id)
{
    await using SqliteConnection connection = CrearConexion();
    await connection.OpenAsync();

    await using SqliteCommand command = connection.CreateCommand();
    command.CommandText = """
    SELECT Id, Nombre, Categoria, Precio, Stock, FechaCreacion
    FROM Productos
    WHERE Id = @id;
    """;

    command.Parameters.AddWithValue("@id", id);

    await using SqliteDataReader reader = await command.ExecuteReaderAsync();

    if (!await reader.ReadAsync())
        return null;

    return MapearProducto(reader);
}

Update: actualizar producto

public async Task<bool> ActualizarAsync(Producto producto)
{
    await using SqliteConnection connection = CrearConexion();
    await connection.OpenAsync();

    await using SqliteCommand command = connection.CreateCommand();
    command.CommandText = """
    UPDATE Productos
    SET Nombre = @nombre,
        Categoria = @categoria,
        Precio = @precio,
        Stock = @stock
    WHERE Id = @id;
    """;

    command.Parameters.AddWithValue("@id", producto.Id);
    command.Parameters.AddWithValue("@nombre", producto.Nombre);
    command.Parameters.AddWithValue("@categoria", producto.Categoria);
    command.Parameters.AddWithValue("@precio", producto.Precio);
    command.Parameters.AddWithValue("@stock", producto.Stock);

    int filas = await command.ExecuteNonQueryAsync();
    return filas == 1;
}

Delete: eliminar producto

public async Task<bool> EliminarAsync(int id)
{
    await using SqliteConnection connection = CrearConexion();
    await connection.OpenAsync();

    await using SqliteCommand command = connection.CreateCommand();
    command.CommandText = "DELETE FROM Productos WHERE Id = @id;";
    command.Parameters.AddWithValue("@id", id);

    int filas = await command.ExecuteNonQueryAsync();
    return filas == 1;
}

Mapear filas a objetos

private static Producto MapearProducto(SqliteDataReader reader)
{
    return new Producto
    {
        Id = reader.GetInt32(reader.GetOrdinal("Id")),
        Nombre = reader.GetString(reader.GetOrdinal("Nombre")),
        Categoria = reader.GetString(reader.GetOrdinal("Categoria")),
        Precio = reader.GetDecimal(reader.GetOrdinal("Precio")),
        Stock = reader.GetInt32(reader.GetOrdinal("Stock")),
        FechaCreacion = DateTime.Parse(reader.GetString(reader.GetOrdinal("FechaCreacion")))
    };
}

8. Programa completo de prueba

using Microsoft.Data.Sqlite;

const string connectionString = "Data Source=tienda.db";

ProductoRepository repository = new(connectionString);
await repository.InicializarAsync();

int id = await repository.CrearAsync(new Producto
{
    Nombre = "Teclado mecánico",
    Categoria = "Periféricos",
    Precio = 89.99m,
    Stock = 12,
    FechaCreacion = DateTime.UtcNow
});

Console.WriteLine($"Producto creado con Id {id}");

Producto? producto = await repository.ObtenerPorIdAsync(id);
Console.WriteLine(producto);

if (producto is not null)
{
    producto.Precio = 79.99m;
    producto.Stock += 5;
    await repository.ActualizarAsync(producto);
}

List<Producto> productos = await repository.ObtenerTodosAsync();

foreach (Producto p in productos)
{
    Console.WriteLine(p);
}

await repository.EliminarAsync(id);
Console.WriteLine("Producto eliminado.");

💡 Consejo: En una aplicación real separarías las clases en archivos: Producto.cs, ProductoRepository.cs y Program.cs.


9. Consultas con filtros y paginación

public async Task<List<Producto>> BuscarAsync(
    string? categoria,
    decimal? precioMaximo,
    int pagina,
    int tamano)
{
    List<Producto> productos = new();

    if (pagina < 1) pagina = 1;
    if (tamano is < 1 or > 100) tamano = 10;

    await using SqliteConnection connection = CrearConexion();
    await connection.OpenAsync();

    await using SqliteCommand command = connection.CreateCommand();
    command.CommandText = """
    SELECT Id, Nombre, Categoria, Precio, Stock, FechaCreacion
    FROM Productos
    WHERE (@categoria IS NULL OR Categoria = @categoria)
      AND (@precioMaximo IS NULL OR Precio <= @precioMaximo)
    ORDER BY Nombre
    LIMIT @tamano OFFSET @offset;
    """;

    command.Parameters.AddWithValue("@categoria", (object?)categoria ?? DBNull.Value);
    command.Parameters.AddWithValue("@precioMaximo", (object?)precioMaximo ?? DBNull.Value);
    command.Parameters.AddWithValue("@tamano", tamano);
    command.Parameters.AddWithValue("@offset", (pagina - 1) * tamano);

    await using SqliteDataReader reader = await command.ExecuteReaderAsync();

    while (await reader.ReadAsync())
    {
        productos.Add(MapearProducto(reader));
    }

    return productos;
}

10. Transacciones

📘 Concepto: Una transacción permite ejecutar varias operaciones como una sola unidad. Si todo va bien se confirma con Commit; si algo falla se cancela con Rollback.

Ejemplo: registrar una venta y descontar stock. Ambas operaciones deben completarse juntas.

public async Task RegistrarVentaAsync(int productoId, int cantidad)
{
    await using SqliteConnection connection = CrearConexion();
    await connection.OpenAsync();

    using SqliteTransaction transaction = connection.BeginTransaction();

    try
    {
        await using SqliteCommand descontarStock = connection.CreateCommand();
        descontarStock.Transaction = transaction;
        descontarStock.CommandText = """
        UPDATE Productos
        SET Stock = Stock - @cantidad
        WHERE Id = @productoId AND Stock >= @cantidad;
        """;
        descontarStock.Parameters.AddWithValue("@productoId", productoId);
        descontarStock.Parameters.AddWithValue("@cantidad", cantidad);

        int filas = await descontarStock.ExecuteNonQueryAsync();

        if (filas != 1)
            throw new InvalidOperationException("No hay stock suficiente.");

        await using SqliteCommand insertarVenta = connection.CreateCommand();
        insertarVenta.Transaction = transaction;
        insertarVenta.CommandText = """
        INSERT INTO Ventas (ProductoId, Cantidad, Fecha)
        VALUES (@productoId, @cantidad, @fecha);
        """;
        insertarVenta.Parameters.AddWithValue("@productoId", productoId);
        insertarVenta.Parameters.AddWithValue("@cantidad", cantidad);
        insertarVenta.Parameters.AddWithValue("@fecha", DateTime.UtcNow.ToString("O"));

        await insertarVenta.ExecuteNonQueryAsync();

        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

11. Propiedades avanzadas de conexión

La cadena de conexión controla detalles importantes:

const string connectionString = """
Data Source=tienda.db;
Mode=ReadWriteCreate;
Cache=Shared;
Pooling=True;
Default Timeout=30
""";
Propiedad Uso
Data Source Archivo SQLite o servidor, según proveedor
Mode Modo de apertura en SQLite
Cache Caché compartida en SQLite
Pooling Reutilización de conexiones
Default Timeout Tiempo máximo de espera

Ejemplo SQL Server:

const string connectionString = """
Server=localhost;
Database=Tienda;
Trusted_Connection=True;
TrustServerCertificate=True;
Connect Timeout=30;
Pooling=True;
Max Pool Size=100;
""";

💡 Consejo: El pooling hace que abrir y cerrar conexiones sea barato. Por eso lo correcto es abrir la conexión lo más tarde posible y cerrarla cuanto antes.


12. Propiedades avanzadas de comandos

await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT COUNT(*) FROM Productos;";
command.CommandTimeout = 30;
command.CommandType = System.Data.CommandType.Text;
Propiedad Significado
CommandText SQL o nombre del procedimiento
CommandTimeout Segundos máximos antes de cancelar
CommandType.Text El comando es SQL normal
CommandType.StoredProcedure El comando llama a un procedimiento almacenado
Transaction Transacción asociada al comando

Ejemplo con SQL Server y procedimiento almacenado:

using Microsoft.Data.SqlClient;
using System.Data;

await using SqlConnection connection = new(connectionString);
await connection.OpenAsync();

await using SqlCommand command = connection.CreateCommand();
command.CommandType = CommandType.StoredProcedure;
command.CommandText = "dbo.ObtenerProductosPorCategoria";
command.CommandTimeout = 60;

command.Parameters.Add(new SqlParameter("@Categoria", SqlDbType.NVarChar, 80)
{
    Value = "Periféricos"
});

await using SqlDataReader reader = await command.ExecuteReaderAsync();

while (await reader.ReadAsync())
{
    Console.WriteLine(reader["Nombre"]);
}

13. DbConnection y código independiente del proveedor

Si quieres escribir código menos acoplado a SQLite o SQL Server, puedes usar las clases base:

using System.Data.Common;

public class ConsultaService
{
    private readonly DbProviderFactory _factory;
    private readonly string _connectionString;

    public ConsultaService(DbProviderFactory factory, string connectionString)
    {
        _factory = factory;
        _connectionString = connectionString;
    }

    public async Task<int> ContarAsync()
    {
        await using DbConnection connection = _factory.CreateConnection()
            ?? throw new InvalidOperationException("No se pudo crear la conexión.");

        connection.ConnectionString = _connectionString;
        await connection.OpenAsync();

        await using DbCommand command = connection.CreateCommand();
        command.CommandText = "SELECT COUNT(*) FROM Productos;";

        object? result = await command.ExecuteScalarAsync();
        return Convert.ToInt32(result);
    }
}

Uso con SQLite:

using Microsoft.Data.Sqlite;

ConsultaService service = new(
    SqliteFactory.Instance,
    "Data Source=tienda.db");

int total = await service.ContarAsync();

💡 Consejo: Esta técnica es útil en librerías, pero en aplicaciones sencillas suele ser más claro usar directamente SqliteConnection o SqlConnection.


14. Leer valores nulos

Las bases de datos pueden devolver NULL. En ADO.NET debes comprobarlo:

int ordinalDescripcion = reader.GetOrdinal("Descripcion");

string? descripcion = reader.IsDBNull(ordinalDescripcion)
    ? null
    : reader.GetString(ordinalDescripcion);

Otra opción usando el indexador:

object valor = reader["Descripcion"];
string? descripcion = valor == DBNull.Value ? null : (string)valor;

15. DataTable: datos desconectados

📘 Concepto: DataTable representa una tabla en memoria. Es útil cuando quieres cargar datos, cerrar la conexión y trabajar con ellos después.

using System.Data;

public async Task<DataTable> ObtenerTablaProductosAsync()
{
    DataTable table = new("Productos");

    table.Columns.Add("Id", typeof(int));
    table.Columns.Add("Nombre", typeof(string));
    table.Columns.Add("Precio", typeof(decimal));

    await using SqliteConnection connection = CrearConexion();
    await connection.OpenAsync();

    await using SqliteCommand command = connection.CreateCommand();
    command.CommandText = "SELECT Id, Nombre, Precio FROM Productos ORDER BY Nombre;";

    await using SqliteDataReader reader = await command.ExecuteReaderAsync();

    while (await reader.ReadAsync())
    {
        table.Rows.Add(
            reader.GetInt32(0),
            reader.GetString(1),
            reader.GetDecimal(2)
        );
    }

    return table;
}

Recorrer un DataTable:

DataTable table = await repository.ObtenerTablaProductosAsync();

foreach (DataRow row in table.Rows)
{
    Console.WriteLine($"{row["Id"]}: {row["Nombre"]} - {row["Precio"]}");
}

⚠️ Importante: DataTable es cómodo, pero no es tan tipado como trabajar con clases propias. En C# moderno se suele preferir mapear a modelos como Producto.


16. DataAdapter en SQL Server

DataAdapter es una pieza clásica de ADO.NET para llenar DataSet o DataTable. Es más habitual en aplicaciones antiguas de escritorio o WinForms.

Ejemplo con SQL Server:

using Microsoft.Data.SqlClient;
using System.Data;

DataTable table = new();

await using SqlConnection connection = new(connectionString);

using SqlDataAdapter adapter = new(
    "SELECT Id, Nombre, Precio FROM Productos ORDER BY Nombre",
    connection);

adapter.Fill(table);

foreach (DataRow row in table.Rows)
{
    Console.WriteLine($"{row["Id"]}: {row["Nombre"]} - {row["Precio"]}");
}

17. Bulk insert sencillo

SQLite no tiene el mismo mecanismo de carga masiva que SQL Server, pero puedes insertar muchos registros de forma eficiente usando una transacción y reutilizando el comando:

public async Task InsertarMuchosAsync(List<Producto> productos)
{
    await using SqliteConnection connection = CrearConexion();
    await connection.OpenAsync();

    using SqliteTransaction transaction = connection.BeginTransaction();
    await using SqliteCommand command = connection.CreateCommand();
    command.Transaction = transaction;
    command.CommandText = """
    INSERT INTO Productos (Nombre, Categoria, Precio, Stock, FechaCreacion)
    VALUES (@nombre, @categoria, @precio, @stock, @fechaCreacion);
    """;

    SqliteParameter nombre = command.Parameters.Add("@nombre", SqliteType.Text);
    SqliteParameter categoria = command.Parameters.Add("@categoria", SqliteType.Text);
    SqliteParameter precio = command.Parameters.Add("@precio", SqliteType.Real);
    SqliteParameter stock = command.Parameters.Add("@stock", SqliteType.Integer);
    SqliteParameter fecha = command.Parameters.Add("@fechaCreacion", SqliteType.Text);

    foreach (Producto producto in productos)
    {
        nombre.Value = producto.Nombre;
        categoria.Value = producto.Categoria;
        precio.Value = producto.Precio;
        stock.Value = producto.Stock;
        fecha.Value = producto.FechaCreacion.ToString("O");

        await command.ExecuteNonQueryAsync();
    }

    await transaction.CommitAsync();
}

Con SQL Server existe SqlBulkCopy, muy útil para cargas grandes:

using Microsoft.Data.SqlClient;

using SqlBulkCopy bulkCopy = new(connectionString);
bulkCopy.DestinationTableName = "Productos";

// bulkCopy.WriteToServer(dataTable);

18. Manejo de errores

try
{
    int id = await repository.CrearAsync(producto);
    Console.WriteLine($"Producto creado: {id}");
}
catch (SqliteException ex) when (ex.SqliteErrorCode == 19)
{
    Console.WriteLine("Error de restricción: puede que el producto esté duplicado.");
}
catch (SqliteException ex)
{
    Console.WriteLine($"Error SQLite: {ex.Message}");
}
catch (Exception ex)
{
    Console.WriteLine($"Error inesperado: {ex.Message}");
}

Ejemplo SQL Server:

catch (SqlException ex) when (ex.Number == 2627)
{
    Console.WriteLine("Clave única duplicada.");
}

💡 Consejo: Captura primero excepciones específicas del proveedor y deja Exception como último recurso.


19. Configuración con appsettings.json

Aunque sea una aplicación de consola, puedes guardar la cadena de conexión fuera del código.

dotnet add package Microsoft.Extensions.Configuration.Json

appsettings.json:

{
  "ConnectionStrings": {
    "Tienda": "Data Source=tienda.db"
  }
}

Lectura:

using Microsoft.Extensions.Configuration;

IConfiguration configuration = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json", optional: false)
    .Build();

string connectionString = configuration.GetConnectionString("Tienda")
    ?? throw new InvalidOperationException("Falta la cadena de conexión.");

⚠️ Importante: No subas contraseñas reales a Git. Usa variables de entorno, Secret Manager o un gestor de secretos.


20. Comparativa de proveedores

Base de datos Paquete NuGet Clases principales
SQLite Microsoft.Data.Sqlite SqliteConnection, SqliteCommand
SQL Server Microsoft.Data.SqlClient SqlConnection, SqlCommand
PostgreSQL Npgsql NpgsqlConnection, NpgsqlCommand
MySQL/MariaDB MySqlConnector MySqlConnection, MySqlCommand

El patrón es casi siempre el mismo:

await using var connection = new TipoConnection(connectionString);
await connection.OpenAsync();

await using var command = connection.CreateCommand();
command.CommandText = "SELECT ...";

await using var reader = await command.ExecuteReaderAsync();

21. Buenas prácticas

Práctica Motivo
Usa parámetros siempre Evita inyección SQL y problemas de formato
Abre conexiones tarde y ciérralas pronto Aprovecha el pooling
Usa await using Libera recursos correctamente
Usa transacciones en operaciones relacionadas Evita datos inconsistentes
Controla CommandTimeout Evita esperas infinitas
Mapea resultados en métodos pequeños Mejora legibilidad
No devuelvas DataReader fuera del repositorio Mantiene controlada la conexión
Guarda connection strings fuera del código Mejora seguridad y despliegue
Prefiere async en I/O La app escala mejor
Registra errores sin filtrar secretos Ayuda a diagnosticar sin exponer datos

22. Ejercicios

Ejercicio 1: CRUD de clientes

Crea una tabla Clientes con Id, Nombre, Email, Telefono y FechaAlta. Implementa:

  • Crear cliente
  • Obtener por Id
  • Buscar por email
  • Actualizar teléfono
  • Eliminar cliente

Ejercicio 2: Pedidos con transacción

Crea tablas Pedidos y DetallePedido. Al crear un pedido:

  • Inserta la cabecera del pedido
  • Inserta sus líneas
  • Descuenta stock
  • Si algo falla, cancela todo con rollback

Ejercicio 3: Buscador avanzado

Implementa una búsqueda de productos con filtros opcionales:

  • Categoría
  • Precio mínimo
  • Precio máximo
  • Texto en el nombre
  • Paginación

Ejercicio 4: Informe con ExecuteScalar

Crea métodos para calcular:

  • Total de productos
  • Stock total
  • Precio medio
  • Producto más caro

Ejercicio 5: Importación masiva

Lee un archivo CSV de productos e insértalos en SQLite usando una transacción.


Resumen

Tema Idea clave
ADO.NET Acceso directo a datos desde .NET
Proveedor Librería específica para cada base de datos
Connection Abre la comunicación con la BD
Command Ejecuta SQL
Parámetros Envían valores de forma segura
ExecuteNonQuery SQL sin filas de resultado
ExecuteScalar Un único valor
ExecuteReader Lectura de filas
Transacción Confirma o cancela varias operaciones
DataTable Datos en memoria
Pooling Reutilización eficiente de conexiones