ASP.NET Core Web API · Data

Stored Procedures

Many companies keep business logic in stored procedures written by their DBAs. The ShopSmart API must call two of them: one that lists products by category and one that adjusts stock and reports the new level. EF Core and ADO.NET both make this straightforward.

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

In simple words#

Stored procedures are pre-agreed recipes kept in the warehouse; the API just says “run recipe PlaceOrder with these values”.

CommandType.StoredProcedure
ADO.NET call.
FromSql
EF Core call for procedures returning rows.
Output parameters
Values sent back.

Learning objectives#

  • Query a stored procedure into DTOs with Database.SqlQuery
  • Execute a procedure with parameters and an OUTPUT parameter
  • Translate errors raised with THROW into API responses
  • Keep calls safe from SQL injection

Explanation#

For result sets, db.Database.SqlQuery<T>(

quot;EXEC dbo.usp_GetProductsByCategory @CategoryId = {id}") (EF Core 8+) maps columns to any class by name. The interpolated values become parameters — it is not string concatenation.

For entity types, db.Products.FromSql(

quot;EXEC …") works too, but you cannot compose further LINQ after a stored procedure call.

For commands, use db.Database.ExecuteSqlAsync(

quot;…") or ExecuteSqlRawAsync with SqlParameter objects when you need OUTPUT parameters (Direction = ParameterDirection.Output).

Procedures signal business errors with THROW 50001, 'message', 1. Catch SqlException, check Number, and return a 422 problem — other numbers are real failures (500).

With plain ADO.NET, set command.CommandType = CommandType.StoredProcedure and add parameters by name.

Syntax#

C# Illustrative
var rows = await db.Database
    .SqlQuery<ProductRow>(
quot;EXEC dbo.usp_GetProductsByCategory @CategoryId =
{categoryId}")
.ToListAsync(ct); var newStock = new SqlParameter("@NewStock", SqlDbType.Int) { Direction = ParameterDirection.Output }; await db.Database.ExecuteSqlRawAsync("EXEC dbo.usp_AdjustStock @ProductId, @Delta, @NewStock OUTPUT", [id, delta, newStock], ct);

Code example: Two stored procedures behind API endpoints#

C#Controllers/StockController.cs Illustrative
// Program.cs
using System.Net.Http.Json;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;


const string Server = @"Server=(localdb)\mssqllocaldb;Trusted_Connection=True;TrustServerCertificate=True";
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();                                    // keep the demo output clean
builder.Services.AddControllers();
builder.Services.AddDbContext<ShopDb>(o => o.UseSqlServer(Server + ";Database=ShopSmartApi_Procs"));
var app = builder.Build();


// Demo setup: the DBA's database, table and procedures
await using (var setup = new SqlConnection(Server + ";Database=master"))
{
    await setup.OpenAsync();
    await new SqlCommand("DROP DATABASE IF EXISTS ShopSmartApi_Procs; CREATE DATABASE ShopSmartApi_Procs;", setup).ExecuteNonQueryAsync();
    setup.ChangeDatabase("ShopSmartApi_Procs");
    foreach (var sql in new[]
    {
        """
        CREATE TABLE Products (Id INT PRIMARY KEY, CategoryId INT NOT NULL, Name NVARCHAR(100) NOT NULL, Price DECIMAL(10,2) NOT NULL, Stock INT NOT NULL);
        INSERT INTO Products VALUES (1, 1, 'Wireless Mouse', 799, 10), (2, 1, 'USB-C Hub', 1499, 5), (3, 2, 'Bluetooth Speaker', 3299, 3);
        """,
        """
        CREATE PROCEDURE dbo.usp_GetProductsByCategory @CategoryId INT AS
            SELECT Id, Name, Price FROM Products WHERE CategoryId = @CategoryId ORDER BY Price;
        """,
        """
        CREATE PROCEDURE dbo.usp_AdjustStock @ProductId INT, @Delta INT, @NewStock INT OUTPUT AS
        BEGIN
            SET NOCOUNT ON;
            UPDATE Products SET @NewStock = Stock = Stock + @Delta WHERE Id = @ProductId AND Stock + @Delta >= 0;
            IF @@ROWCOUNT = 0 THROW 50001, 'Stock cannot go below zero (or the product does not exist).', 1;
        END
        """,
    })
        await new SqlCommand(sql, setup).ExecuteNonQueryAsync();
}


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


using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5215") };
Console.WriteLine(
quot;Category 1:
{await http.GetStringAsync("/api/categories/1/products")}");
foreach (var delta in new[] { -3, 20, -50 }) { var response = await http.PostAsJsonAsync("/api/products/2/stock-adjustments", new { delta }); var body = await response.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>(); var text = response.IsSuccessStatusCode ? body.ToString() :
quot;problem:
{body.GetProperty("title")}";
Console.WriteLine(
quot;Adjust product 2 by
{delta,3}: {(int)response.StatusCode} {text}");
} await app.StopAsync(); SqlConnection.ClearAllPools(); await using (var cleanup = new SqlConnection(Server + ";Database=master")) { await cleanup.OpenAsync(); await new SqlCommand("DROP DATABASE ShopSmartApi_Procs;", cleanup).ExecuteNonQueryAsync(); } // Data/ShopDb.cs using Microsoft.EntityFrameworkCore; public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options); public class ProductRow // shape of the procedure's result set { public int Id { get; set; } public string Name { get; set; } = ""; public decimal Price { get; set; } } // Controllers/StockController.cs using System.Data; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; public record AdjustStockRequest(int Delta); [ApiController] [Route("api")] public class StockController(ShopDb db) : ControllerBase { [HttpGet("categories/{categoryId:int}/products")] public async Task<List<ProductRow>> ByCategory(int categoryId, CancellationToken ct) => await db.Database .SqlQuery<ProductRow>(
quot;EXEC dbo.usp_GetProductsByCategory @CategoryId =
{categoryId}") // parameterised
.ToListAsync(ct); [HttpPost("products/{id:int}/stock-adjustments")] public async Task<IActionResult> Adjust(int id, AdjustStockRequest request, CancellationToken ct) { var newStock = new SqlParameter("@NewStock", SqlDbType.Int) { Direction = ParameterDirection.Output }; try { await db.Database.ExecuteSqlRawAsync( "EXEC dbo.usp_AdjustStock @ProductId, @Delta, @NewStock OUTPUT", [new SqlParameter("@ProductId", id), new SqlParameter("@Delta", request.Delta), newStock], ct); return Ok(new { productId = id, newStock = (int)newStock.Value }); } catch (SqlException ex) when (ex.Number == 50001) // the procedure's business error { return Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: ex.Message); } } }
Expected output
Category 1: [{"id":1,"name":"Wireless Mouse","price":799.00},{"id":2,"name":"USB-C Hub","price":1499.00}]
Adjust product 2 by  -3: 200 {"productId":2,"newStock":2}
Adjust product 2 by  20: 200 {"productId":2,"newStock":22}
Adjust product 2 by -50: 422 problem: Stock cannot go below zero (or the product does not exist).

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. The setup creates the table and the two procedures the way a DBA would deliver them.
  2. SqlQuery<ProductRow> runs usp_GetProductsByCategory with a real parameter and maps the columns by name.
  3. ExecuteSqlRawAsync calls usp_AdjustStock with an OUTPUT parameter that carries the new stock level back.
  4. Removing 3 and then adding 20 succeed (5 → 2 → 22).
  5. Removing 50 would go below zero, so the procedure THROWs error 50001, which becomes a 422 problem response.

Real-world use case#

ShopSmart’s warehouse integration calls the warehouse team’s usp_AllocateStock and usp_ConfirmShipment procedures; their THROW codes are mapped to 409 and 422 responses the mobile picking app understands.

Common mistakes#

String-concatenating EXEC calls

quot;EXEC usp_X '{name}'" passed to ExecuteSqlRaw is injectable. Use the interpolated APIs (SqlQuery, ExecuteSql) or SqlParameter.

Composing LINQ after FromSql on a procedure

.Where() after an EXEC cannot be translated (a procedure cannot be a subquery). Filter in the procedure or after ToListAsync.

Catching every SqlException as a business error

Deadlocks and timeouts are not “invalid input”. Check ex.Number.

Best practices#

  • SqlQuery<T> / FromSql with interpolation
  • SqlParameter for OUTPUT values
  • Custom THROW numbers (50000+) for business errors
  • Version procedures with migrations or scripts

Remember this#

SqlQuery<T>(

quot;EXEC proc @p = {value}") — interpolation = parameters.

OUTPUT → SqlParameter with Direction = Output.

THROW 50001 → catch SqlException Number → 422.

Practice exercise#

Your task: Call usp_GetProductsByCategory with plain ADO.NET (CommandType.StoredProcedure) and return the product names.

Need a hint?
new SqlCommand("dbo.usp_GetProductsByCategory", connection) { CommandType = CommandType.StoredProcedure } and Parameters.AddWithValue or Add("@CategoryId", SqlDbType.Int).
Show solution
C# Illustrative
// Data/ProcedureData.cs
using System.Data;
using Microsoft.Data.SqlClient;


public class ProcedureData(string connectionString)
{
    public async Task<List<string>> GetNamesByCategoryAsync(int categoryId, CancellationToken ct)
    {
        await using var connection = new SqlConnection(connectionString);
        await connection.OpenAsync(ct);
        await using var command = new SqlCommand("dbo.usp_GetProductsByCategory", connection)
        {
            CommandType = CommandType.StoredProcedure,
        };
        command.Parameters.Add("@CategoryId", SqlDbType.Int).Value = categoryId;
        await using var reader = await command.ExecuteReaderAsync(ct);
        var names = new List<string>();
        while (await reader.ReadAsync(ct)) names.Add(reader.GetString(reader.GetOrdinal("Name")));
        return names;
    }
}

Mini quiz#

Stored Procedures quizMultiple choice Question 1 of 3

Is

quot;EXEC dbo.usp_X @Id = {id}" passed to Database.SqlQuery vulnerable to SQL injection?

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.