Stored Procedures vs ORM — Choosing the Right Approach for the BlogApp

The decision between stored procedures and EF Core is not binary — the BlogApp uses both strategically. EF Core handles CRUD operations with its superior developer experience (change tracking, migrations, compile-time safety, testability). Stored procedures handle complex, performance-critical operations where T-SQL excels: multi-step transactional operations, complex analytics, and bulk data operations. Understanding how to call stored procedures and raw SQL from EF Core bridges both worlds without compromising on either.

EF Core + Stored Procedures

// ── Calling a stored procedure from EF Core ───────────────────────────────

// 1. ExecuteSqlInterpolated — for procedures that don't return rows
public async Task PublishPostAsync(int postId, string userId, CancellationToken ct)
{
    var postIdParam  = new SqlParameter("@PostId",     postId);
    var userIdParam  = new SqlParameter("@PublishedBy", userId);
    var outParam     = new SqlParameter("@NewPostId",   SqlDbType.Int)
                           { Direction = ParameterDirection.Output };

    await _db.Database.ExecuteSqlRawAsync(
        "EXEC dbo.usp_PublishPost @PostId, @PublishedBy, @NewPostId OUTPUT",
        postIdParam, userIdParam, outParam, ct);

    int publishedId = (int)outParam.Value;
}

// 2. FromSqlInterpolated — for procedures that return result rows
public async Task<IReadOnlyList<PostSummaryDto>> GetPublishedAsync(
    int page, int size, string? category, CancellationToken ct)
{
    return await _db.Database
        .SqlQuery<PostSummaryDto>(
            $"EXEC dbo.usp_GetPublishedPosts {page}, {size}, {category}")
        .ToListAsync(ct);
    // FromSqlInterpolated parameterises automatically — no injection risk
}

// 3. Raw SQL queries for complex analytics (no stored procedure needed)
public async Task<IReadOnlyList<AuthorStatsDto>> GetAuthorLeaderboardAsync(
    CancellationToken ct)
{
    var sql = @"
        WITH AuthorStats AS (
            SELECT u.Id, u.DisplayName,
                   COUNT(p.Id) AS PostCount, SUM(p.ViewCount) AS TotalViews
            FROM   dbo.Users u JOIN dbo.Posts p ON p.AuthorId = u.Id
            WHERE  p.IsPublished = 1
            GROUP  BY u.Id, u.DisplayName
        )
        SELECT *, DENSE_RANK() OVER (ORDER BY TotalViews DESC) AS Rank
        FROM   AuthorStats ORDER BY TotalViews DESC";

    return await _db.Database
        .SqlQuery<AuthorStatsDto>(FormattableStringFactory.Create(sql))
        .ToListAsync(ct);
}

// ── Decision framework ────────────────────────────────────────────────────
// EF Core is better for:
// ✅ Simple CRUD (Posts.GetById, Posts.Create, Posts.Update)
// ✅ Migrations and schema changes (code-first)
// ✅ Unit testable code (mock DbContext)
// ✅ Change tracking and related entity loading (Include())
// ✅ LINQ-expressible queries (most data access)

// Stored Procedures / Raw SQL is better for:
// ✅ Multi-step transactional operations (PublishPost)
// ✅ Complex analytics with CTEs, window functions
// ✅ Bulk operations (usp_SetPostTags with TVP)
// ✅ Performance-critical queries the optimizer handles better as SQL
// ✅ Operations shared across multiple applications / non-.NET callers
Note: FromSqlInterpolated() and Database.SqlQuery<T>() both accept C# interpolated strings and automatically parameterise every interpolated value — they are injection-safe despite looking like string interpolation. FromSqlRaw() accepts a plain string with SqlParameter objects for manual parameterisation. Never use FromSqlRaw() with string concatenation for user input — use FromSqlInterpolated() or explicit SqlParameter objects. The EF Core team chose the interpolated approach specifically to make the safe path look natural.
Tip: Map stored procedure result sets to DTOs rather than entity types when the shape differs from the entity. Create a separate PostSummaryDto class matching the procedure’s column names and register it with EF Core as a keyless entity: modelBuilder.Entity<PostSummaryDto>().HasNoKey(). This allows _db.Set<PostSummaryDto>().FromSqlInterpolated() queries without requiring a primary key on the DTO. EF Core 8+ supports SqlQuery<T>() directly on DatabaseFacade for even simpler raw SQL to DTO mapping.
Warning: Overusing stored procedures couples your application tightly to SQL Server. If the organisation ever needs to support PostgreSQL or Azure SQL Managed Instance with different T-SQL dialects, stored procedures in SQL Server-specific T-SQL must be rewritten. EF Core queries targeting standard SQL features are far more portable. Use stored procedures where they provide clear performance or functional advantages — not as the default for all data access. The hybrid approach (EF Core CRUD + raw SQL/procs for complex cases) gives the best of both worlds.

Decision Matrix

Operation Type Recommended Approach
Single entity CRUD EF Core (Add, Update, Remove)
Simple list with filter/page EF Core LINQ (Where, Skip, Take)
Multi-table join (2-3 tables) EF Core with Include/ThenInclude
Complex analytics (CTEs, window fns) Raw SQL or Stored Procedure
Multi-step atomic operation Stored Procedure with transaction
Bulk insert/update EF Core ExecuteUpdate or Stored Proc with TVP
Shared with non-.NET systems Stored Procedure

Common Mistakes

Mistake 1 — Using FromSqlRaw with string concatenation (SQL injection)

❌ Wrong — FromSqlRaw($"SELECT * FROM Posts WHERE Slug = '{slug}'"); injection vulnerability.

✅ Correct — FromSqlInterpolated($"SELECT * FROM Posts WHERE Slug = {slug}"); auto-parameterised.

Mistake 2 — All data access in stored procedures (tightly coupled to SQL Server, hard to test)

❌ Wrong — every SELECT and CRUD operation in a stored procedure; cannot unit test; schema changes require SP updates.

✅ Correct — EF Core for CRUD and standard queries; stored procedures only for operations that genuinely benefit.

🧠 Test Yourself

An EF Core query uses FromSqlInterpolated($"SELECT * FROM Posts WHERE Slug = {userInput}"). A malicious user passes userInput = "'; DROP TABLE Posts; --". What happens?