Módulo 1 · C# — Capítulo 15

ADO.NET Puro — SQL sem Entity Framework

Sem Entity Framework, sem Dapper — só as classes que o próprio .NET já traz para falar com o SQL Server. Entender essa camada de baixo nível explica o que todo ORM faz por baixo dos panos.

1. Por que aprender isso, se existe EF Core e Dapper

O capítulo 13 já mostrou EF Core e o Módulo 2 (capítulo 13) mostrou Dapper — as duas formas mais usadas no dia a dia. Mas vale entender a camada por baixo delas, System.Data.SqlClient (ou seu sucessor, Microsoft.Data.SqlClient), por três motivos práticos: controle total sobre performance em código crítico, projetos que não podem (ou não devem) trazer dependências extras, e — o mais importante para você agora — entender exatamente o que um ORM está fazendo por você é o que separa "usar uma ferramenta" de "saber o que ela esconde".

2. SqlConnection — a conexão com o banco

C# abrindo e fechando uma conexão
string connectionString = "Server=localhost;Database=CursoDb;Trusted_Connection=True;TrustServerCertificate=True;";

using var conexao = new SqlConnection(connectionString);
await conexao.OpenAsync();

// ... usar a conexão ...

// o 'using' garante Dispose() no final do escopo, fechando a conexão automaticamente
Connection Pooling

Fechar uma SqlConnection não derruba a conexão TCP de verdade com o SQL Server — o ADO.NET mantém um pool de conexões físicas já abertas em segundo plano. "Abrir" uma conexão geralmente só pega uma emprestada do pool; "fechar" a devolve para reuso. É por isso que abrir/fechar uma SqlConnection por operação (como todos os exemplos deste curso já fazem) é seguro e barato — o custo caro (handshake TCP, autenticação) só acontece quando o pool precisa criar uma conexão física nova.

3. SqlCommand e parâmetros — nunca concatene valores na query

C# SqlCommand parametrizado
using var comando = new SqlCommand(
    "SELECT Id, Nome, Email FROM dbo.Cliente WHERE Nome LIKE @Nome",
    conexao);

comando.Parameters.Add("@Nome", SqlDbType.NVarChar, 100).Value = "%" + termoBusca + "%";
O mesmo princípio do capítulo 10 do Módulo 2

comando.Parameters.Add(...) é a versão C# de sp_executesql com parâmetros tipados — nunca construa a query como "...WHERE Nome LIKE '%" + termoBusca + "%'". Isso é SQL injection exatamente como visto naquele capítulo, só que a porta de entrada agora é o código C#, não o T-SQL dinâmico.

4. Os três jeitos de executar um comando

MétodoQuando usarDevolve
ExecuteNonQueryAsync()INSERT/UPDATE/DELETE, sem result setNúmero de linhas afetadas
ExecuteScalarAsync()Quando a query devolve um único valorO primeiro valor da primeira linha, como object
ExecuteReaderAsync()Quando a query devolve várias linhas/colunasUm SqlDataReader para iterar
C# os três em contexto
// ExecuteNonQuery — um UPDATE
using (var cmd = new SqlCommand("UPDATE dbo.Cliente SET Ativo = 0 WHERE Id = @Id", conexao))
{
    cmd.Parameters.AddWithValue("@Id", 42);
    int linhasAfetadas = await cmd.ExecuteNonQueryAsync();
}

// ExecuteScalar — contar registros
using (var cmd = new SqlCommand("SELECT COUNT(*) FROM dbo.Cliente WHERE Ativo = 1", conexao))
{
    int total = (int)await cmd.ExecuteScalarAsync()!;
}

// ExecuteReader — várias linhas
using (var cmd = new SqlCommand("SELECT Id, Nome FROM dbo.Cliente WHERE Ativo = 1", conexao))
using (var leitor = await cmd.ExecuteReaderAsync())
{
    while (await leitor.ReadAsync())
    {
        int id = leitor.GetInt32(leitor.GetOrdinal("Id"));
        string nome = leitor.GetString(leitor.GetOrdinal("Nome"));
    }
}

5. Mapeamento manual — o que Dapper faz por você

Sem um micro-ORM, mapear SqlDataReader para objetos é responsabilidade sua. Um método auxiliar reaproveitável evita repetir esse código toda vez:

C# mapeamento manual isolado num método
static Cliente MapearCliente(SqlDataReader leitor) => new()
{
    Id    = leitor.GetInt32(leitor.GetOrdinal("Id")),
    Nome  = leitor.GetString(leitor.GetOrdinal("Nome")),
    Email = leitor.GetString(leitor.GetOrdinal("Email")),
    Ativo = leitor.GetBoolean(leitor.GetOrdinal("Ativo")),
};

var clientes = new List<Cliente>();
using var leitor = await comando.ExecuteReaderAsync();
while (await leitor.ReadAsync())
    clientes.Add(MapearCliente(leitor));

6. Transações com SqlTransaction

Assim como BEGIN TRANSACTION/COMMIT no T-SQL (Módulo 2, capítulo 5), o ADO.NET tem seu próprio controle de transação — útil quando você precisa coordenar múltiplos comandos separados a partir do C#, não dentro de uma única procedure:

C# SqlTransaction
await using var conexao = new SqlConnection(connectionString);
await conexao.OpenAsync();

await using var transacao = await conexao.BeginTransactionAsync();

try
{
    await using (var cmd1 = new SqlCommand("UPDATE dbo.Conta SET Saldo = Saldo - @V WHERE Id = @Origem", conexao, (SqlTransaction)transacao))
    {
        cmd1.Parameters.AddWithValue("@V", 100m);
        cmd1.Parameters.AddWithValue("@Origem", 1);
        await cmd1.ExecuteNonQueryAsync();
    }

    await using (var cmd2 = new SqlCommand("UPDATE dbo.Conta SET Saldo = Saldo + @V WHERE Id = @Destino", conexao, (SqlTransaction)transacao))
    {
        cmd2.Parameters.AddWithValue("@V", 100m);
        cmd2.Parameters.AddWithValue("@Destino", 2);
        await cmd2.ExecuteNonQueryAsync();
    }

    await transacao.CommitAsync();
}
catch
{
    await transacao.RollbackAsync();
    throw;
}
Dica

Sempre que possível, prefira colocar essa lógica dentro de uma única procedure (como usp_ContaTransferir, Módulo 2 capítulo 5) — a transação inteira roda no servidor, com menos idas e voltas de rede. Use SqlTransaction do lado do C# quando os comandos realmente precisam ser decididos em tempo de execução pela aplicação (por exemplo, coordenando chamadas a mais de um banco de dados diferente).

7. Um Repository só com ADO.NET — sem Dapper, sem EF Core

C# ClienteRepository — versão ADO.NET puro
public class ClienteRepositoryAdoNet : IClienteRepository
{
    private readonly string _connectionString;
    public ClienteRepositoryAdoNet(string connectionString) => _connectionString = connectionString;

    public async Task<Cliente?> ObterPorIdAsync(int id)
    {
        await using var conexao = new SqlConnection(_connectionString);
        await using var comando = new SqlCommand("dbo.usp_ClienteObter", conexao)
        {
            CommandType = CommandType.StoredProcedure
        };
        comando.Parameters.Add("@Id", SqlDbType.Int).Value = id;

        await conexao.OpenAsync();
        await using var leitor = await comando.ExecuteReaderAsync();

        return await leitor.ReadAsync() ? MapearCliente(leitor) : null;
    }

    private static Cliente MapearCliente(SqlDataReader leitor) => new()
    {
        Id    = leitor.GetInt32(leitor.GetOrdinal("Id")),
        Nome  = leitor.GetString(leitor.GetOrdinal("Nome")),
        Email = leitor.GetString(leitor.GetOrdinal("Email")),
    };
}

Essa classe implementa exatamente a mesma interface IClienteRepository do capítulo 12 — o resto da aplicação (Service, Controller) não muda uma linha, não importa qual das três abordagens (ADO.NET, Dapper, EF Core) esteja por trás da interface. Essa é a recompensa prática de programar contra abstrações.

8. Onde isso te leva

Com as três camadas de acesso a dados entendidas — ADO.NET puro, Dapper e EF Core — você tem o quadro completo de opções para qualquer projeto C#/SQL Server, e o entendimento de baixo nível para escolher com intenção, não por hábito. Isso fecha o Módulo 1; os módulos seguintes constroem arquiteturas inteiras (MVC, APIs, Worker Services, Desktop) em cima dessa mesma base.

📌 Resumo do capítulo

  • SqlConnection, SqlCommand e SqlDataReader são a camada base sobre a qual Dapper e EF Core são construídos.
  • Connection pooling torna abrir/fechar uma SqlConnection por operação seguro e barato.
  • ExecuteNonQuery (escrita), ExecuteScalar (um valor) e ExecuteReader (várias linhas) cobrem todos os casos de execução.
  • Parâmetros (SqlParameter) protegem contra SQL injection — nunca concatene valores na string do comando.
  • SqlTransaction coordena múltiplos comandos a partir do C#, quando isso não pode ser resolvido dentro de uma única procedure.

✏️ Praticando

  1. Implemente ClienteRepositoryAdoNet completo (Obter, Listar, Inserir, Atualizar, Excluir), chamando as procedures do Módulo 2 sem usar Dapper nem EF Core.
  2. Escreva uma versão do checkout do Módulo 2 (capítulo 14) usando SqlTransaction do lado do C# em vez de uma única procedure, coordenando duas chamadas separadas — e compare a complexidade com a versão original.
  3. Explique, em suas próprias palavras, o que o connection pooling evita que aconteça a cada requisição.