• Bazy danych i SQL
  • ADO.NET w .NET od podstaw - SQL Server, parametry i transakcje

ADO.NET w .NET od podstaw - SQL Server, parametry i transakcje

Radosław Krajewski 22 czerwca 2026
Porównanie wersji SQL Server 2022: Express, Standard i Enterprise. Tabela przedstawia cechy, zastosowania i licencje, w tym kluczowe funkcje .NET.

Spis treści

Gdy aplikacja .NET ma pobierać dane z SQL Servera, wykonywać zapytania i bezpiecznie zapisywać wyniki, potrzebuje warstwy komunikacji z bazą. ADO.NET dostarcza właśnie takich interfejsów i klas, a w tym artykule pokazuję jego architekturę, praktyczne przykłady w C#, parametryzację, transakcje, wydajność oraz różnice względem Entity Framework Core i Dappera.

Najważniejsze informacje o dostępie do danych w .NET

  • ADO.NET to niskopoziomowy zestaw interfejsów do pracy z bazami danych i innymi źródłami danych.
  • W przypadku SQL Servera w nowych aplikacjach najczęściej stosuje się pakiet Microsoft.Data.SqlClient.
  • Podstawowy przepływ obejmuje połączenie, polecenie, parametry i odczyt wyników.
  • Parametry SQL chronią przed SQL injection i pomagają poprawnie obsługiwać typy danych.
  • ADO.NET daje dużą kontrolę i wydajność, ale wymaga napisania większej ilości kodu niż EF Core.

Czym właściwie jest ADO.NET

ADO.NET to część ekosystemu .NET odpowiedzialna za komunikację z bazami danych. Nie jest jedną klasą ani pojedynczym sterownikiem. To zestaw wspólnych abstrakcji, takich jak DbConnection, DbCommand, DbDataReader i DbTransaction, które współpracują z konkretnymi dostawcami danych.

Dzięki temu aplikacja może otworzyć połączenie, wykonać zapytanie SQL, odczytać rekordy albo zapisać zmiany. Sam model jest stosunkowo prosty, ale daje programiście pełną kontrolę nad tym, jakie zapytanie trafia do bazy, kiedy połączenie jest otwierane i jak obsługiwane są transakcje.

W praktyce korzysta się z dostawcy dopasowanego do konkretnego systemu. Dla SQL Servera będzie to zwykle Microsoft.Data.SqlClient, dla PostgreSQL dostawca Npgsql, a dla SQLite Microsoft.Data.Sqlite. Wspólne klasy bazowe pozostają podobne, lecz szczegóły połączenia i możliwości zależą od dostawcy.

Jak wygląda architektura tego rozwiązania

Najłatwiej myśleć o ADO.NET jak o krótkim łańcuchu operacji. Aplikacja otwiera połączenie, przekazuje polecenie, dodaje parametry, uruchamia zapytanie i odczytuje rezultat. Każdy element ma osobną odpowiedzialność, co ułatwia kontrolowanie zasobów i diagnozowanie błędów.

Element Rola Typowy przykład
Connection Łączy aplikację ze źródłem danych SqlConnection
Command Wykonuje SQL lub procedurę składowaną SqlCommand
Parameter Przekazuje wartości do zapytania SqlParameter
DataReader Odczytuje wynik w sposób strumieniowy SqlDataReader
DataAdapter Wypełnia obiekty typu DataSet lub DataTable SqlDataAdapter
Transaction Łączy kilka operacji w jedną całość SqlTransaction

W nowych aplikacjach najczęściej wybieram wariant oparty na połączeniu, komendzie i czytniku. Obiekty DataSet i DataTable nadal mają zastosowanie, szczególnie w starszych aplikacjach desktopowych lub rozwiązaniach, które potrzebują danych pracujących chwilowo poza bazą.

ADO.NET oddziela warstwę dostępu do danych od samej bazy. Oznacza to, że ta sama koncepcja może obsługiwać SQL Server, PostgreSQL czy SQLite, choć zmieni się konkretny provider i składnia niektórych poleceń.

Pierwsze połączenie z SQL Serverem w C#

Minimalny przykład powinien pokazywać pełny cykl, a nie samo wywołanie Open. Połączenie trzeba poprawnie zwolnić, zapytanie powinno używać parametrów, a odczyt warto wykonywać asynchronicznie w aplikacji webowej.

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

await using var connection = new SqlConnection(
    configuration.GetConnectionString("DefaultConnection"));

await connection.OpenAsync();

const string sql = """
    SELECT Id, Name, Price
    FROM dbo.Products
    WHERE Price >= @minimumPrice
    ORDER BY Name;
    """;

await using var command = new SqlCommand(sql, connection);
command.Parameters.Add("@minimumPrice", SqlDbType.Decimal).Value = 100m;

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

while (await reader.ReadAsync())
{
    var id = reader.GetInt32(reader.GetOrdinal("Id"));
    var name = reader.GetString(reader.GetOrdinal("Name"));
    var price = reader.GetDecimal(reader.GetOrdinal("Price"));

    Console.WriteLine($"{id}: {name} - {price} zł");
}

Blok await using zamyka zasoby nawet wtedy, gdy podczas odczytu wystąpi wyjątek. Dzięki temu połączenie może wrócić do connection poolingu, czyli puli gotowych połączeń utrzymywanej przez sterownik. Nie trzeba otwierać fizycznego połączenia z serwerem od zera przy każdym zapytaniu.

Łańcuch połączenia powinien znajdować się w konfiguracji, zmiennej środowiskowej albo bezpiecznym magazynie sekretów. Nie wpisuję haseł bezpośrednio do kodu i nie umieszczam ich w repozytorium, nawet jeśli projekt jest prywatny.

Odczyt i zapis danych bez typowych błędów

Wybór właściwej metody wykonania

Rodzaj metody powinien wynikać z oczekiwanego rezultatu. ExecuteReaderAsync służy do wielu wierszy, ExecuteScalarAsync do jednej wartości, a ExecuteNonQueryAsync do poleceń takich jak INSERT, UPDATE i DELETE.

Metoda Kiedy jej użyć Przykład wyniku
ExecuteReaderAsync Odczyt wielu kolumn i rekordów Lista produktów
ExecuteScalarAsync Pobranie jednej wartości Liczba zamówień
ExecuteNonQueryAsync Zmiana danych Liczba zmodyfikowanych wierszy

Parametry zamiast konkatenacji

Nie należy budować zapytań przez sklejanie danych użytkownika z tekstem SQL. Taki kod jest nie tylko trudniejszy do utrzymania, lecz przede wszystkim otwiera drogę do SQL injection. Parametry oddzielają treść polecenia od wartości i pozwalają providerowi prawidłowo przekazać typy danych.

const string sql = """
    INSERT INTO dbo.Customers (Name, Email)
    VALUES (@name, @email);
    """;

await using var command = new SqlCommand(sql, connection);

command.Parameters.Add("@name", SqlDbType.NVarChar, 150).Value = customer.Name;
command.Parameters.Add("@email", SqlDbType.NVarChar, 254).Value = customer.Email;

var affectedRows = await command.ExecuteNonQueryAsync();

Podawanie typu i długości parametru jest szczególnie ważne przy tekstach, liczbach dziesiętnych i datach. Samo przekazanie obiektu jako wartości często działa, ale jawne określenie typu daje bardziej przewidywalne zapytania i ogranicza problemy z konwersją.

Przeczytaj również: LAG w SQL bez błędów - PARTITION BY, ORDER BY i przykłady

NULL wymaga osobnej obsługi

Wartość NULL z bazy nie jest tym samym co pusty tekst albo wartość domyślna. Przy odczycie trzeba sprawdzić reader.IsDBNull, a przy zapisie użyć DBNull.Value, jeśli parametr ma reprezentować brak wartości.

var description = reader.IsDBNull(3)
    ? null
    : reader.GetString(3);

command.Parameters.Add("@description", SqlDbType.NVarChar, 500)
    .Value = description ?? (object)DBNull.Value;

Transakcje, bezpieczeństwo i wydajność

Transakcja ma sens wtedy, gdy kilka operacji musi zakończyć się wspólnie. Przykładem może być utworzenie zamówienia oraz pomniejszenie stanu magazynowego. Jeśli druga operacja się nie powiedzie, pierwsza również powinna zostać wycofana.

await using var transaction = await connection.BeginTransactionAsync();

try
{
    await using var orderCommand = new SqlCommand(
        "INSERT INTO dbo.Orders (CustomerId) VALUES (@customerId);",
        connection,
        (SqlTransaction)transaction);

    orderCommand.Parameters.Add("@customerId", SqlDbType.Int).Value = customerId;
    await orderCommand.ExecuteNonQueryAsync();

    await using var stockCommand = new SqlCommand(
        """
        UPDATE dbo.Products
        SET Stock = Stock - @quantity
        WHERE Id = @productId AND Stock >= @quantity;
        """,
        connection,
        (SqlTransaction)transaction);

    stockCommand.Parameters.Add("@quantity", SqlDbType.Int).Value = quantity;
    stockCommand.Parameters.Add("@productId", SqlDbType.Int).Value = productId;

    var changed = await stockCommand.ExecuteNonQueryAsync();

    if (changed != 1)
        throw new InvalidOperationException("Brak wystarczającego stanu magazynowego.");

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

Najczęstszy błąd polega na rozpoczęciu transakcji i pominięciu jej przy przypisywaniu do kolejnej komendy. Wszystkie polecenia, które mają być objęte transakcją, muszą korzystać z tego samego połączenia i obiektu transakcji.

Wydajność zależy nie tylko od samego sterownika. Dużo większe znaczenie mają indeksy, liczba zwracanych kolumn, rozmiar wyników i czas trwania zapytania. Nie pobieram SELECT *, gdy potrzebuję trzech pól, a duże zbiory przetwarzam strumieniowo przez DataReader zamiast ładować je w całości do pamięci.

W aplikacjach webowych warto ustawić rozsądny timeout, logować czas wykonania zapytań i obserwować pulę połączeń. Długie operacje SQL blokują zasoby niezależnie od tego, czy kod C# wygląda na asynchroniczny.

ADO.NET, Entity Framework Core czy Dapper

Nie traktuję tych technologii jako bezpośrednich zamienników w każdej sytuacji. Wszystkie mogą korzystać z SQL Servera, ale oferują inny poziom abstrakcji i inną kontrolę nad kodem.

Rozwiązanie Największa zaleta Główne ograniczenie Dobre zastosowanie
ADO.NET Pełna kontrola nad SQL i zasobami Więcej kodu ręcznego Wydajne zapytania, integracje, specjalistyczne moduły
Entity Framework Core Praca z encjami i śledzeniem zmian Mniejsza kontrola nad szczegółami bez dodatkowej konfiguracji Większość aplikacji biznesowych i CRUD
Dapper Prosty mapping SQL na obiekty Nadal trzeba samodzielnie pisać SQL i pilnować schematu Lekkie API i projekty z dużą liczbą ręcznych zapytań

Jeżeli buduję typowy system biznesowy z wieloma encjami i relacjami, zwykle zaczynam od EF Core. Gdy potrzebuję precyzyjnego SQL, bardzo szybkiego odczytu albo integracji z nietypowym mechanizmem bazy, schodzę niżej i wybieram ADO.NET lub bibliotekę opartą na jego abstrakcjach.

Najważniejsze jest zachowanie granicy odpowiedzialności. Nawet przy użyciu EF Core trzeba rozumieć połączenia, transakcje, indeksy i parametry. Bez tej wiedzy łatwo napisać kod, który działa na małej bazie, ale zaczyna zwalniać przy większym ruchu.

Kiedy bezpośredni dostęp do bazy ma największy sens

ADO.NET sprawdza się szczególnie dobrze w miejscach, gdzie liczy się przewidywalny czas wykonania, niewielki narzut i możliwość dokładnego sterowania zapytaniem. Dotyczy to między innymi raportów, importów, eksportów, zadań wsadowych oraz fragmentów systemów o dużym obciążeniu.

  • Gdy zapytanie jest złożone i chcesz kontrolować jego plan wykonania.
  • Gdy aplikacja zwraca duże wyniki i potrzebujesz odczytu strumieniowego.
  • Gdy korzystasz z procedur składowanych lub funkcji specyficznych dla SQL Servera.
  • Gdy tworzysz bibliotekę, która ma działać z różnymi providerami ADO.NET.
  • Gdy narzut pełnego ORM-u byłby większy niż korzyść z automatycznego mapowania.

Nie wybierałbym jednak tego podejścia wyłącznie dlatego, że jest „szybsze”. W wielu aplikacjach największy koszt wynika z nieoptymalnych zapytań i złego modelu danych, a nie z użycia ORM-u. Ręczne pisanie SQL ma sens wtedy, gdy rzeczywiście potrzebna jest kontrola albo prostota, a nie jako obowiązkowy etap każdego projektu.

Dobry punkt startu do pracy z bazą w .NET

Na początku warto opanować pięć rzeczy: otwieranie i zamykanie połączeń, parametryzowanie poleceń, właściwy wybór metody wykonania, bezpieczny odczyt wartości oraz transakcje. Ten zestaw wystarcza do budowania solidnych modułów dostępu do danych i ułatwia późniejszą pracę z EF Core albo Dapperem.

Moja praktyczna rada jest prosta. Najpierw napisz kilka małych operacji na Microsoft.Data.SqlClient, zmierz ich działanie i naucz się czytać plan zapytania w SQL Serverze. Dopiero później wybierz wyższy poziom abstrakcji, gdy będziesz wiedzieć, które problemy rozwiązuje za Ciebie, a które nadal pozostają po stronie kodu i bazy.

Takie podejście pozwala traktować ADO.NET nie jako przestarzały mechanizm, lecz jako fundament, na którym działają również współczesne biblioteki dostępu do danych w świecie .NET.

FAQ - Najczęstsze pytania

Podstawowy przepływ obejmuje połączenie przez SqlConnection, polecenie w SqlCommand, parametry oraz odczyt wyników przez SqlDataReader. W nowych aplikacjach SQL Server najczęściej obsługuje pakiet Microsoft.Data.SqlClient.

Parametry oddzielają dane od treści zapytania, chronią przed SQL injection i pomagają poprawnie przekazywać typy. Warto jawnie określać typ oraz długość parametru, a brak wartości zapisywać jako DBNull.Value i sprawdzać przy odczycie przez reader.IsDBNull.

Wszystkie operacje objęte transakcją muszą korzystać z tego samego połączenia i obiektu transakcji. Po pomyślnym wykonaniu poleceń wywołuje się CommitAsync, a po błędzie RollbackAsync, aby zmiany zostały wycofane jako całość.

ADO.NET sprawdza się, gdy potrzebujesz pełnej kontroli nad SQL, przewidywalnego czasu wykonania, odczytu strumieniowego albo obsługi procedur specyficznych dla SQL Servera. EF Core zwykle lepiej pasuje do aplikacji biznesowych z encjami i śledzeniem zmian, a Dapper do lekkiego mapowania ręcznie pisanych zapytań na obiekty.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

sql server
parametryzacja
transakcje
entity framework core
ado.net
Autor Radosław Krajewski
Radosław Krajewski
Nazywam się Radosław Krajewski i od 6 lat zgłębiam tajniki programowania .NET, chmury Azure oraz sztucznej inteligencji. Moja przygoda z tymi technologiami zaczęła się od fascynacji tym, jak złożone problemy można rozwiązywać za pomocą kodu i innowacyjnych narzędzi. Staram się przekazywać tę wiedzę w sposób zrozumiały, dzieląc się swoimi doświadczeniami i spostrzeżeniami na kursdotnet.pl. W moich artykułach skupiam się na praktycznych aspektach, porównuję różne rozwiązania i analizuję najnowsze trendy, aby dostarczyć Wam rzetelne i aktualne informacje, które pomogą Wam rozwijać się w tej dynamicznie zmieniającej się dziedzinie.

Udostępnij artykuł

Napisz komentarz