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.
On this page · 8 min · Intermediate
On this page
- In simple words
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 8 min
- Difficulty
- Intermediate
- Updated
- Oct 29, 2026
Related tutorials
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#
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#
// 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);
}
}
}
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#
- The setup creates the table and the two procedures the way a DBA would deliver them.
- SqlQuery<ProductRow> runs usp_GetProductsByCategory with a real parameter and maps the columns by name.
- ExecuteSqlRawAsync calls usp_AdjustStock with an OUTPUT parameter that carries the new stock level back.
- Removing 3 and then adding 20 succeed (5 → 2 → 22).
- 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#
.Where() after an EXEC cannot be translated (a procedure cannot be a subquery). Filter in the procedure or after ToListAsync.
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?
Show solution
// 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#
Is
quot;EXEC dbo.usp_X @Id = {id}" passed to Database.SqlQuery vulnerable to SQL injection?Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions
My notes#
Saved in this browser.