ASP.NET Core Web API · Data

ADO.NET Integration

Some API endpoints need full control over SQL — a high-traffic checkout, a legacy schema, a hand-tuned query. ADO.NET gives you that control. Here the ShopSmart API places orders with plain SqlClient, inside a transaction that either reserves all items or none.

Intermediate 9 min Lesson 12 of 37Examples target .NET 9.
0/37 in ASP.NET Core Web API
On this page · 9 min · Intermediate

In simple words#

Using ADO.NET in an API is cooking from scratch: you write each SQL query yourself — fast and fully controlled.

Repository
Holds the SQL code.
Parameters
Prevent SQL injection.
async
Use ExecuteReaderAsync in APIs.

Learning objectives#

  • Inject a data class that reads its connection string from configuration
  • Read rows with SqlDataReader into DTOs
  • Run several commands in one SqlTransaction
  • Pass the request’s CancellationToken down to the database

Explanation#

Register a data class (scoped or singleton — it only holds a connection string) and inject IConfiguration or an options class to get the connection string.

Open a pooled SqlConnection per operation with await using, run parameterised SqlCommands, and map rows from SqlDataReader into DTOs.

A transaction makes several commands atomic: BeginTransactionAsync, pass the transaction to every command, then CommitAsync — or RollbackAsync if any step fails. Here, UPDATE … WHERE Stock >= @quantity reserves stock safely even with concurrent orders.

Accept a CancellationToken in actions (ASP.NET Core binds it to the request) and pass it to OpenAsync, ExecuteReaderAsync and friends. If the client disconnects, the database work stops too.

Return problem details for expected failures (not enough stock → 409) and let the transaction guarantee nothing partial was saved.

Syntax#

C# Illustrative
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(ct);
await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync(ct);
await using var command = new SqlCommand(sql, connection, transaction);
…
await transaction.CommitAsync(ct);

Code example: Placing orders with SqlClient and a transaction#

C#Data/SqlOrderData.cs Illustrative
// appsettings.json
{
  "ConnectionStrings": {
    "Shop": "Server=(localdb)\\mssqllocaldb;Database=ShopSmartApi_Ado;Trusted_Connection=True;TrustServerCertificate=True"
  }
}


// Program.cs
using System.Net.Http.Json;
using Microsoft.Data.SqlClient;


var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();                                    // keep the demo output clean
builder.Services.AddControllers();
builder.Services.AddScoped<SqlOrderData>();
var app = builder.Build();


// Demo setup: a fresh database with two products
const string Master = @"Server=(localdb)\mssqllocaldb;Database=master;Trusted_Connection=True;TrustServerCertificate=True";
await using (var setup = new SqlConnection(Master))
{
    await setup.OpenAsync();
    await new SqlCommand("DROP DATABASE IF EXISTS ShopSmartApi_Ado; CREATE DATABASE ShopSmartApi_Ado;", setup).ExecuteNonQueryAsync();
    setup.ChangeDatabase("ShopSmartApi_Ado");
    await new SqlCommand("""
        CREATE TABLE Products (Id INT PRIMARY KEY, Name NVARCHAR(100) NOT NULL, Price DECIMAL(10,2) NOT NULL, Stock INT NOT NULL);
        CREATE TABLE Orders (Id INT IDENTITY PRIMARY KEY, Email NVARCHAR(200) NOT NULL, Total DECIMAL(12,2) NOT NULL,
                             PlacedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME());
        INSERT INTO Products VALUES (1, 'Wireless Mouse', 799, 10), (2, 'USB-C Hub', 1499, 5);
        """, setup).ExecuteNonQueryAsync();
}


app.MapControllers();
app.Urls.Add("http://127.0.0.1:5212");
await app.StartAsync();


using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5212") };
async Task ShowStock() => Console.WriteLine(
quot;Stock:
{string.Join(", ",
(await http.GetFromJsonAsync<List<ProductDto>>("/api/products"))!.Select(p =>
quot;
{p.Name} {p.Stock}"))}");
await ShowStock(); var ok = await http.PostAsJsonAsync("/api/orders", new { email = "asha@example.com", lines = new[] { new { productId = 1, quantity = 2 }, new { productId = 2, quantity = 1 } } }); Console.WriteLine(
quot;Order 1:
{(int)ok.StatusCode} {await ok.Content.ReadAsStringAsync()}");
var tooMany = await http.PostAsJsonAsync("/api/orders", new { email = "ravi@example.com", lines = new[] { new { productId = 1, quantity = 1 }, new { productId = 2, quantity = 9 } } }); Console.WriteLine(
quot;Order 2:
{(int)tooMany.StatusCode} (stock for product 1 was reserved, then rolled back)");
await ShowStock(); await app.StopAsync(); SqlConnection.ClearAllPools(); await using (var cleanup = new SqlConnection(Master)) { await cleanup.OpenAsync(); await new SqlCommand("DROP DATABASE ShopSmartApi_Ado;", cleanup).ExecuteNonQueryAsync(); } // Data/SqlOrderData.cs using System.Data; using Microsoft.Data.SqlClient; public record ProductDto(int Id, string Name, decimal Price, int Stock); public record OrderLineRequest(int ProductId, int Quantity); public record PlaceOrderRequest(string Email, List<OrderLineRequest> Lines); public class SqlOrderData(IConfiguration configuration) { private readonly string _connectionString = configuration.GetConnectionString("Shop")!; public async Task<List<ProductDto>> GetProductsAsync(CancellationToken ct) { await using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(ct); await using var command = new SqlCommand("SELECT Id, Name, Price, Stock FROM Products ORDER BY Id", connection); await using var reader = await command.ExecuteReaderAsync(ct); var products = new List<ProductDto>(); while (await reader.ReadAsync(ct)) products.Add(new ProductDto(reader.GetInt32(0), reader.GetString(1), reader.GetDecimal(2), reader.GetInt32(3))); return products; } // Returns the new order id, or null when any product lacks stock (nothing is saved then). public async Task<int?> PlaceOrderAsync(PlaceOrderRequest request, CancellationToken ct) { await using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(ct); await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync(ct); decimal total = 0; foreach (var line in request.Lines) { await using var reserve = new SqlCommand(""" UPDATE Products SET Stock = Stock - @quantity OUTPUT INSERTED.Price WHERE Id = @id AND Stock >= @quantity """, connection, transaction); reserve.Parameters.Add("@quantity", SqlDbType.Int).Value = line.Quantity; reserve.Parameters.Add("@id", SqlDbType.Int).Value = line.ProductId; if (await reserve.ExecuteScalarAsync(ct) is not decimal price) { await transaction.RollbackAsync(ct); // undo the lines already reserved return null; } total += price * line.Quantity; } await using var insert = new SqlCommand( "INSERT INTO Orders (Email, Total) OUTPUT INSERTED.Id VALUES (@email, @total)", connection, transaction); insert.Parameters.Add("@email", SqlDbType.NVarChar, 200).Value = request.Email; insert.Parameters.Add("@total", SqlDbType.Decimal).Value = total; var orderId = (int)(await insert.ExecuteScalarAsync(ct))!; await transaction.CommitAsync(ct); return orderId; } } // Controllers/ShopController.cs using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api")] public class ShopController(SqlOrderData data) : ControllerBase { [HttpGet("products")] public Task<List<ProductDto>> Products(CancellationToken ct) => data.GetProductsAsync(ct); [HttpPost("orders")] public async Task<IActionResult> PlaceOrder(PlaceOrderRequest request, CancellationToken ct) => await data.PlaceOrderAsync(request, ct) is { } id ? Created(
quot;/api/orders/
{id}", new { id })
: Problem(statusCode: StatusCodes.Status409Conflict, title: "Not enough stock", detail: "No changes were made."); }
Expected output
Stock: Wireless Mouse 10, USB-C Hub 5
Order 1: 201 {"id":1}
Order 2: 409 (stock for product 1 was reserved, then rolled back)
Stock: Wireless Mouse 8, USB-C Hub 4

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. SqlOrderData reads the connection string from configuration and opens a pooled connection per call.
  2. The first order reserves stock for both lines, inserts the order and commits.
  3. The second order reserves product 1, then cannot reserve 9 hubs; RollbackAsync undoes the first reservation.
  4. The API answers 409 with problem details, and the stock list proves nothing changed.
  5. CancellationToken parameters flow from ASP.NET Core into every database call.

Real-world use case#

ShopSmart’s flash-sale checkout uses exactly this pattern: an atomic conditional UPDATE per line inside a transaction, so thousands of concurrent buyers can never oversell the 500 discounted items.

Common mistakes#

Checking stock with SELECT, then updating

Between the SELECT and the UPDATE another request can buy the last item. Make the check part of the UPDATE (WHERE Stock >= @quantity).

Forgetting to pass the transaction to commands

A command without the transaction fails (“ExecuteReader requires the command to have a transaction”) or runs outside it.

Ignoring CancellationToken

Without it, abandoned requests keep the database busy. Pass it everywhere.

Best practices#

  • Connection string from configuration
  • Parameterised commands only
  • Transactions for multi-step writes
  • CancellationToken on every async call

Remember this#

Begin → commands with the transaction → Commit / Rollback.

Conditional UPDATE = race-free stock reservation.

CancellationToken in, CancellationToken down.

Practice exercise#

Your task: Add GET /api/orders/{id} that returns { id, email, total } using ExecuteReaderAsync, or 404 when the order does not exist.

Need a hint?
SELECT Id, Email, Total FROM Orders WHERE Id = @id, then if (!await reader.ReadAsync(ct)) return NotFound();
Show solution
C# Illustrative
// Controllers/OrdersController.cs
using System.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;


[ApiController]
[Route("api/orders")]
public class OrdersController(IConfiguration configuration) : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<IActionResult> Get(int id, CancellationToken ct)
    {
        await using var connection = new SqlConnection(configuration.GetConnectionString("Shop"));
        await connection.OpenAsync(ct);
        await using var command = new SqlCommand("SELECT Id, Email, Total FROM Orders WHERE Id = @id", connection);
        command.Parameters.Add("@id", SqlDbType.Int).Value = id;
        await using var reader = await command.ExecuteReaderAsync(ct);
        if (!await reader.ReadAsync(ct)) return NotFound();
        return Ok(new { id = reader.GetInt32(0), email = reader.GetString(1), total = reader.GetDecimal(2) });
    }
}

Mini quiz#

ADO.NET Integration quizMultiple choice Question 1 of 3

Why reserve stock with UPDATE … WHERE Stock >= @quantity instead of SELECT then UPDATE?

Score so far: 0/0

Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.