Every LINQ query you'll ever write against a database eventually becomes a connection, a command, and a reader — this is that layer.
You've spent the last several lessons building on dependency injection, configuration, and logging. Now it's time to make your application actually talk to a database — store an order, look up a customer, update an inventory count. Almost every real-world .NET application does this.
In a few lessons, you'll be using Entity Framework Core to do this with C# objects and LINQ, barely thinking about SQL at all. That's genuinely great for productivity. But EF Core isn't magic — underneath every query it runs, every row it saves, there's a much older, much lower-level piece of .NET quietly doing the actual work: ADO.NET.
In this lesson, you'll learn what ADO.NET is, the handful of building-block types it's built from, and why understanding this "under the floorboards" layer will make you a noticeably better EF Core developer — not a worse one.
ADO.NET is the part of .NET whose entire job is: open a connection to a database, send it a command written in that database's own language (SQL), and hand back whatever data comes back — as raw rows and columns, not as C# objects. It doesn't know what a Product class is. It only knows connections, commands, and rows of data.
ADO.NET is a set of classes, defined mostly in the System.Data and System.Data.Common namespaces, that provide a standard, provider-independent way to interact with relational databases. It defines a small family of abstract base types — DbConnection, DbCommand, DbDataReader, DbParameter, DbTransaction — and each database vendor ships a provider that implements those abstractions for their specific database engine.
For SQL Server, that provider is the Microsoft.Data.SqlClient NuGet package (the modern, actively-maintained namespace — not the older, legacy System.Data.SqlClient you may still see in tutorials). It gives you concrete types like SqlConnection, SqlCommand, and SqlDataReader, each of which derives from the provider-agnostic DbConnection, DbCommand, and DbDataReader base classes.
| Provider-Agnostic Base Type | SQL Server Implementation | What It Represents |
|---|---|---|
| DbConnection | SqlConnection | An open line to a specific database |
| DbCommand | SqlCommand | One SQL statement you want to run |
| DbDataReader | SqlDataReader | A fast, forward-only stream of result rows |
| DbParameter | SqlParameter | A single, safely-typed value passed into a command |
| DbTransaction | SqlTransaction | A group of statements that must all succeed or all fail together |
Every one of these gets its own dedicated lesson right after this one — this lesson is about seeing how they fit together as a system, before zooming into each piece.
A relational database like SQL Server, PostgreSQL, or SQLite doesn't understand C# classes, properties, or objects. It understands a text-based query language — SQL — sent over a network connection, and it responds with rows and columns of raw data (strings, numbers, dates — no types like Product or Customer exist on its side at all).
Somewhere, something has to:
Before a standard existed, every database vendor had its own bespoke way of doing this from .NET, and code that talked to SQL Server looked nothing like code that talked to Oracle. Switching databases meant rewriting your entire data-access layer from scratch.
ADO.NET's answer was to define one common shape — DbConnection, DbCommand, DbDataReader, and friends — that every database provider agrees to implement. Your code, working against those shapes, looks structurally the same whether you're talking to SQL Server, PostgreSQL, or SQLite. Only the concrete provider types and the connection string change.
Where does ADO.NET actually sit? Right between your application code and the physical database server:
Notice that EF Core doesn't skip ADO.NET — it uses it. Every query EF Core runs still ultimately becomes a DbCommand executed over a DbConnection, whose results come back through a DbDataReader. EF Core's real job is generating that SQL for you and mapping the reader's rows back into your C# objects automatically.
This four-step shape — connect → command → execute/read → dispose — is the backbone of every single ADO.NET interaction, and it's exactly what EF Core is automating for you under the hood.
Reading a list of product names directly with ADO.NET, no EF Core involved at all:
using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";
using SqlConnection connection = new(connectionString);
connection.Open();
using SqlCommand command = connection.CreateCommand();
command.CommandText = "SELECT Id, Name, Price FROM Products WHERE Price > @minPrice";
command.Parameters.AddWithValue("@minPrice", 20.00m);
using SqlDataReader reader = command.ExecuteReader();
while (reader.Read())
{
int id = reader.GetInt32(0);
string name = reader.GetString(1);
decimal price = reader.GetDecimal(2);
Console.WriteLine($"{id}: {name} - {price:C}");
}
// connection and reader are disposed automatically when they go out of scopeWalking through it:
Even in an application built entirely on EF Core, ADO.NET doesn't disappear from view completely. A common real-world scenario: a nightly reporting job needs to run a heavily-optimized, hand-tuned aggregate query — total revenue per region for the last quarter — where every millisecond and every byte of the generated SQL matters, and the team wants full manual control rather than trusting an ORM to generate exactly the right query plan.
using Microsoft.Data.SqlClient;
public async Task<List<RegionRevenue>> GetQuarterlyRevenueByRegionAsync(string connectionString)
{
var results = new List<RegionRevenue>();
await using SqlConnection connection = new(connectionString);
await connection.OpenAsync();
await using SqlCommand command = connection.CreateCommand();
command.CommandText = """
SELECT Region, SUM(TotalAmount) AS Revenue
FROM Orders
WHERE OrderDate >= @quarterStart
GROUP BY Region
ORDER BY Revenue DESC
""";
command.Parameters.AddWithValue("@quarterStart", DateTime.UtcNow.AddMonths(-3));
await using SqlDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results.Add(new RegionRevenue(
reader.GetString(0),
reader.GetDecimal(1)));
}
return results;
}
public record RegionRevenue(string Region, decimal Revenue);This is a genuinely common pattern in real .NET systems: use EF Core for the vast majority of everyday CRUD-style data access, but reach for raw ADO.NET (or EF Core's own raw-SQL escape hatches, which you'll meet later) for the small handful of queries where hand-tuned SQL matters more than convenience.
ADO.NET is like making the phone call yourself: you dial the number (open the connection), read out exactly what you want (the SQL command), and personally write down every detail of the answer, word for word (reading the raw data reader).
EF Core is like having a personal assistant: you tell them "get me last quarter's top customers," and they make the call, take the notes, and hand you back a neatly organized report (fully-formed C# objects) — but underneath, they're still making the exact same kind of phone call you would have made yourself.
Knowing how to make the call yourself means you understand what your assistant is actually doing — and you can step in and make the call personally when you need that extra level of control.
This is worth being explicit about, because it's the single most useful mental model for the rest of Part VI: Entity Framework Core is, at its core, a very sophisticated generator of ADO.NET calls. When you write:
var expensiveProducts = await dbContext.Products
.Where(p => p.Price > 20)
.ToListAsync();EF Core does roughly the same four steps you just wrote by hand above:
Every "mystery" you'll ever encounter in EF Core — a slow query, an unexpected SQL statement, a connection-pool exhaustion error — ultimately traces back down to this same ADO.NET layer. That's precisely why this module starts here instead of jumping straight to DbContext.
ADO.NET isn't a competing, outdated alternative to EF Core — it's the foundation EF Core is built on. EF Core doesn't replace ADO.NET; it wraps it and generates its calls for you. Even in the newest .NET 10 applications built entirely with EF Core, ADO.NET types are working underneath every single query, whether you ever see them or not.
Yes. System.Data.SqlClient is the legacy, in-maintenance-mode namespace that shipped with the full .NET Framework. Microsoft.Data.SqlClient is its modern, actively-developed successor — it's what EF Core's SQL Server provider uses internally, and it's what you should reach for in any new .NET code that talks to SQL Server directly.
You can absolutely be productive with EF Core without ever writing a line of raw ADO.NET. But when a query behaves unexpectedly, when you need to reason about connection pooling, or when you hit EF Core's raw-SQL escape hatches, understanding this layer is what turns "EF Core is doing something weird" into "I know exactly what SQL and connection behavior is happening here."
Some learners skip straight to EF Core and treat ADO.NET as irrelevant trivia. In practice, this layer resurfaces constantly: connection string troubleshooting, understanding timeout exceptions, reading logged SQL, and occasionally writing a raw query for performance. A basic working knowledge pays for itself repeatedly.
using System.Data.SqlClient; in a brand-new .NET 10 project — it still works, but it's effectively frozen, receiving only critical security fixes. Reference the Microsoft.Data.SqlClient NuGet package instead; it's a near drop-in replacement with active development, better performance, and newer SQL Server feature support.
Reaching for raw ADO.NET for every single query "because it's faster," before ever measuring anything. For the overwhelming majority of everyday queries, EF Core's generated SQL is perfectly efficient, and the productivity and maintainability gains are enormous. Default to EF Core; drop to raw ADO.NET (or EF Core's raw-SQL features) only for the specific, measured cases where it demonstrably matters.
You've seen the layer that sits beneath every database interaction in .NET. Let's check it stuck.
1. What is the relationship between ADO.NET and Entity Framework Core?
Correct: B
Why B is correct: EF Core is a higher-level abstraction that, underneath, still opens a DbConnection, builds a DbCommand, and reads results through a DbDataReader — exactly the ADO.NET pattern. It automates that layer rather than replacing it.
Why A is incorrect: They aren't competitors in the sense of mutually exclusive technologies — EF Core depends on ADO.NET to function at all.
Why C is incorrect: It's the reverse — ADO.NET predates EF Core and is the lower-level foundation, not a newer replacement.
Why D is incorrect: Neither EF Core nor ADO.NET replaces the database server — both are ways for .NET code to talk to it.
Reinforcement: EF Core is best understood as "ADO.NET, automated" — not a separate, unrelated technology.
2. Which ADO.NET type is responsible for streaming the actual rows returned by a query back to your code?
Correct: C
Why C is correct: A DbDataReader (e.g. SqlDataReader) is a forward-only cursor over the result rows — you call Read() repeatedly to advance through them.
Why A is incorrect: A DbConnection represents the open line to the database — it doesn't itself hold row data.
Why B is incorrect: A DbCommand represents the SQL statement to execute — executing it is what produces a reader, but the command itself isn't the row stream.
Why D is incorrect: A DbParameter represents a single safely-typed input value passed into a command — it has nothing to do with reading results back.
Reinforcement: Connection opens the line, command describes the request, reader delivers the response.
3. Why does ADO.NET define provider-agnostic base classes like DbConnection rather than requiring every vendor to invent its own unrelated API?
Correct: B
Why B is correct: The shared base types mean your data-access code looks structurally similar regardless of which database you're targeting, and it lets frameworks like EF Core be built against one common abstraction instead of one per database vendor.
Why A is incorrect: ADO.NET doesn't equalize performance — different databases and providers still perform differently; it only standardizes the API shape.
Why C is incorrect: They are genuinely different database engines with different internals — ADO.NET standardizes how .NET code talks to them, not what they are underneath.
Why D is incorrect: ADO.NET is literally the mechanism for sending raw SQL — it doesn't discourage or prevent it.
Reinforcement: A shared abstraction is what makes portability and higher-level tooling like EF Core possible in the first place.
4. Which NuGet package should a new .NET 10 project use to talk to SQL Server via ADO.NET directly?
Correct: B
Why B is correct: Microsoft.Data.SqlClient is the modern SQL Server ADO.NET provider — the one EF Core's SQL Server provider itself relies on internally, and the recommended choice for new code.
Why A is incorrect: System.Data.SqlClient still works but is legacy and receives only critical fixes — not the right choice for new projects.
Why C is incorrect: ADO.NET providers exist for many databases, including SQL Server, PostgreSQL, and SQLite — it's not SQLite-only.
Why D is incorrect: EF Core's SQL Server provider itself depends on Microsoft.Data.SqlClient under the hood — it doesn't eliminate the need for a database client library, it builds on one.
Reinforcement: Always favor Microsoft.Data.SqlClient over the legacy System.Data.SqlClient in modern .NET code.
You now understand the ADO.NET layer that quietly powers every database interaction in .NET. Next up: a closer look at the first building block — database connections.
dotnetmadeeasy.com — Learn C# and .NET, the right way.