ASP.NET Web API (.NET Framework) · Data

Stored Procedures

Many enterprise databases expose their logic as stored procedures. Your Web API 2 code calls them, passes parameters, reads results — and turns SQL errors into proper HTTP responses.

Intermediate 6 min Lesson 15 of 36Covers ASP.NET Web API 2.2 on .NET Framework 4.8.
0/36 in ASP.NET Web API
On this page · 6 min · Intermediate

Learning objectives#

  • Call a stored procedure with ADO.NET and CommandType.StoredProcedure
  • Use input and output parameters
  • Map a result set to a class with EF6 Database.SqlQuery<T>
  • Translate a THROW from SQL into a 409 response

Explanation#

Set command.CommandType = CommandType.StoredProcedure and use the procedure name as the command text. Add parameters exactly as for normal SQL.

Output parameters need Direction = ParameterDirection.Output; read their Value after the command runs.

EF6 can map procedure results to any class whose property names match the columns: db.Database.SqlQuery<OrderSummary>("EXEC dbo.usp_CustomerSummary @CustomerId", new SqlParameter(...)).

Procedures report business errors with THROW 50001, 'message', 1. In C# that arrives as a SqlException with Number 50001 — catch it and return 409 or 400.

Procedures keep data logic close to the data and let DBAs tune it. The trade-off: logic is split between C# and SQL, and is harder to unit test. Many teams use procedures for complex reports and transactional operations, and EF6 for simple CRUD.

Syntax#

SQL Illustrative
CREATE OR ALTER PROCEDURE dbo.usp_PlaceOrder
    @ProductId INT, @Quantity INT, @OrderId INT OUTPUT
AS
BEGIN
    SET NOCOUNT ON;
    BEGIN TRANSACTION;
    UPDATE dbo.Products SET Stock = Stock - @Quantity
    WHERE Id = @ProductId AND Stock >= @Quantity;
    IF @@ROWCOUNT = 0
    BEGIN
        ROLLBACK;
        THROW 50001, 'Not enough stock.', 1;
    END
    INSERT INTO dbo.Orders (ProductId, Quantity) VALUES (@ProductId, @Quantity);
    SET @OrderId = SCOPE_IDENTITY();
    COMMIT;
END

Code example: Calling procedures from a repository and a controller#

C#OrderProcedures.cs Illustrative
using System.Configuration;
using System.Data;
using System.Data.Entity;
using System.Data.SqlClient;
using System.Net;
using System.Threading.Tasks;
using System.Web.Http;
using ShopApi.Data;      // ShopDbContext


public class OrderSummary
{
    public int OrderCount { get; set; }
    public decimal TotalSpent { get; set; }
}


public class OrderProcedures
{
    private readonly string _cs = ConfigurationManager.ConnectionStrings["ShopDb"].ConnectionString;


    // ADO.NET: input + output parameters
    public async Task<int> PlaceOrderAsync(int productId, int quantity)
    {
        using (var connection = new SqlConnection(_cs))
        using (var command = new SqlCommand("dbo.usp_PlaceOrder", connection))
        {
            command.CommandType = CommandType.StoredProcedure;
            command.Parameters.Add("@ProductId", SqlDbType.Int).Value = productId;
            command.Parameters.Add("@Quantity", SqlDbType.Int).Value = quantity;
            var orderId = command.Parameters.Add("@OrderId", SqlDbType.Int);
            orderId.Direction = ParameterDirection.Output;


            await connection.OpenAsync();
            await command.ExecuteNonQueryAsync();
            return (int)orderId.Value;
        }
    }


    // EF6: map a result set to a class
    public async Task<OrderSummary> GetCustomerSummaryAsync(int customerId)
    {
        using (var db = new ShopDbContext())
        {
            return await db.Database
                .SqlQuery<OrderSummary>("EXEC dbo.usp_CustomerSummary @CustomerId",
                    new SqlParameter("@CustomerId", customerId))
                .SingleAsync();
        }
    }
}


public class PlaceOrderRequest
{
    public int ProductId { get; set; }
    public int Quantity { get; set; }
}


public class OrdersController : ApiController
{
    private readonly OrderProcedures _procedures = new OrderProcedures();


    [HttpPost, Route("api/orders")]
    public async Task<IHttpActionResult> Place(PlaceOrderRequest request)
    {
        try
        {
            int orderId = await _procedures.PlaceOrderAsync(request.ProductId, request.Quantity);
            return Created("api/orders/" + orderId, new { orderId });
        }
        catch (SqlException ex) when (ex.Number == 50001)
        {
            return Content(HttpStatusCode.Conflict, new { message = ex.Message });
        }
    }


    [HttpGet, Route("api/customers/{id:int}/summary")]
    public async Task<IHttpActionResult> Summary(int id)
    {
        return Ok(await _procedures.GetCustomerSummaryAsync(id));
    }
}
Expected output
POST /api/orders {"ProductId":3,"Quantity":2}   → 201 {"orderId":17}
POST /api/orders {"ProductId":3,"Quantity":99}  → 409 {"message":"Not enough stock."}
GET  /api/customers/5/summary                   → 200 {"OrderCount":4,"TotalSpent":2196.00}

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 procedure does the stock check, update and insert in one transaction on the server.
  2. In C#, CommandType.StoredProcedure plus the procedure name replaces the SQL text.
  3. @OrderId is an output parameter; its Value is read after ExecuteNonQueryAsync.
  4. THROW 50001 becomes a SqlException with Number 50001; the exception filter (when) catches only that one and returns 409.
  5. Database.SqlQuery<OrderSummary> maps the procedure’s columns OrderCount and TotalSpent to the class by name.

Real-world use case#

A bank’s core system exposes transfers only through audited stored procedures. The Web API 2 layer validates input, calls usp_Transfer with parameters, and maps each THROW number to a clear HTTP error.

Common mistakes#

Building EXEC strings with concatenation

"EXEC usp_Find '" + name + "'" is just as injectable as any other SQL. Use parameters.

Forgetting SET NOCOUNT ON

Without it, “rows affected” messages can confuse some clients and add network chatter. Start procedures with SET NOCOUNT ON.

Catching every SqlException as 409

Timeouts and deadlocks are server problems, not conflicts. Filter on specific error numbers.

Best practices#

  • Use CommandType.StoredProcedure, not "EXEC ..." text, in ADO.NET
  • Reserve custom error numbers (50001+) for business errors
  • Keep procedures focused and versioned in source control
  • Wrap multi-step changes in a transaction inside the procedure

Remember this#

CommandType.StoredProcedure + procedure name.

Output parameter: Direction = Output, read Value after executing.

THROW 50001 in SQL → SqlException.Number 50001 in C#.

Practice exercise#

Your task: Call a procedure dbo.usp_SearchProducts @Term NVARCHAR(50) that returns Id, Name, Price, using EF6 Database.SqlQuery into a ProductRow class, and return the list from GET api/products/proc-search?term=pen.

Need a hint?
SqlQuery<ProductRow>("EXEC dbo.usp_SearchProducts @Term", new SqlParameter("@Term", term)).ToListAsync().
Show solution
C# Illustrative
using System.Data.Entity;
using System.Data.SqlClient;
using System.Threading.Tasks;
using System.Web.Http;
using ShopApi.Data;


public class ProductRow
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}


public class ProcSearchController : ApiController
{
    [HttpGet, Route("api/products/proc-search")]
    public async Task<IHttpActionResult> Search(string term = "")
    {
        using (var db = new ShopDbContext())
        {
            var rows = await db.Database
                .SqlQuery<ProductRow>("EXEC dbo.usp_SearchProducts @Term", new SqlParameter("@Term", term))
                .ToListAsync();
            return Ok(rows);
        }
    }
}

Mini quiz#

Stored Procedures quizMultiple choice Question 1 of 3

How do you mark a parameter as an output parameter in ADO.NET?

Score so far: 0/0

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

My notes#

Saved in this browser.

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