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.
