Dart logo
SQL Server logo
Technology logo
Technology logo
How I Made a Reporting API 20× Faster: EF Core vs. Legacy SQL

2025-11-29

How I Made a Reporting API 20× Faster: EF Core vs. Legacy SQL

.NETEF CoreSQL ServerPerformance OptimizationRefactoringDatabase

There was a time at work when I felt incredibly proud.

We had an old transaction report API used heavily for internal reconciliation. That endpoint was slow not normal slow it took more than 40 seconds to load. The reconciliation team repeatedly approached me to fix it, but the stored procedure behind it was huge, complicated, and honestly… something I avoided for a long time.

One day, with a good mood and a bit of courage, I finally decided to dig into it.

Like many legacy reporting stored procedures, it started simple "just fetch some data with filters" but over the years it grew into a massive, multi-purpose SQL monster. And I found multiple deep-rooted problems.

This blog walks through:

  • ✔ The actual issues inside the legacy SP
  • ✔ The principles to fix them
  • ✔ EF Core examples
  • ✔ Patterns that brought the API down from 40s → < 2s (95% improvement)

1. Presentation Logic Inside SQL (Anti-Pattern)

The stored procedure was formatting values inside SQL:

  • Converting numbers to formatted strings
  • Re-parsing those strings back
  • Enforcing decimal precision instead of letting the app layer handle it

Why this is bad

SQL should return raw structured data, not formatted output.


❌ Bad SQL Example

SELECT 
    CONVERT(varchar(20), CAST(Amount AS DECIMAL(10,2))) AS AmountFormatted
FROM Transactions

✅ Better EF Core Example (Formatting in C#)

var result = await _context.Transactions
    .AsNoTracking()
    .Select(x => new 
    {
        x.Id,
        Amount = x.Amount,
        AmountFormatted = x.Amount.ToString("N2")
    })
    .ToListAsync();

2. Overuse of Table Variables

The SP used multiple table variables, acting like temporary caches for millions of rows.


Why this caused problems

  • Table variables don't maintain statistics
  • SQL Server assumes they return 1 row, leading to horrible query plans
  • Pagination was done after loading everything into the table variable

❌ Bad SQL Pattern

DECLARE @t TABLE (...);

INSERT INTO @t
SELECT * FROM Transactions;

SELECT * FROM @t
ORDER BY CreatedOn
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY;

✅ EF Core Query Instead

var query = _context.Transactions.AsNoTracking();

var paged = await query
    .OrderByDescending(x => x.CreatedOn)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync();

Pagination happens before materializing any data.

3. Non-SARGable Filters (Silent Performance Killers)

The stored procedure had several filtering patterns that completely disabled index seeks, forcing SQL Server to scan the entire table.

A filter is SARGable (Search ARGument Able) only when SQL Server can use an index to locate matching rows efficiently.
When you wrap a column in a function or computation, SQL Server loses the ability to use the index.


Common Non-SARGable Patterns Found


1. Functions on Indexed Columns

WHERE CONVERT(VARCHAR(10), CreatedOn, 112) = @Date

This forces a full table scan, even if CreatedOn is indexed.


2. Wildcard on Both Sides

WHERE CustomerName LIKE '%' + @Name + '%'

No index can be used — SQL must scan every row.


3. Computed Values in the WHERE Clause

WHERE (Amount + Tax) = @Total

SQL Server cannot use the index on Amount or Tax.

✔ Correct SARGable Rewrites


1. Use Range Filters Instead of Converting Date Columns

❌ Bad

WHERE CONVERT(VARCHAR(10), CreatedOn, 112) = @Date

✅ Good

WHERE CreatedOn >= @StartDate
  AND CreatedOn < DATEADD(day, 1, @StartDate)

EF Core Equivalent:

query = query.Where(x =>
    x.CreatedOn >= request.StartDate &&
    x.CreatedOn < request.StartDate.AddDays(1));


2. Convert %term% Searches to Index-Friendly Patterns

If business logic allows prefix search:

EF Core:

query = query.Where(x => x.CustomerName.StartsWith(request.Name));

Generated SQL:

WHERE CustomerName LIKE @Name + '%'

→ Index seek friendly.

If full substring search %term% is required, consider Full-Text Search.



3. Avoid Computations on Column Values

❌ Bad

WHERE (Amount + Tax) = @Total

✅ Good

WHERE Amount = @Total - Tax

EF Core:

query = query.Where(x => x.Amount == request.Total - x.Tax);

4. Business Rules Hidden Inside SQL CASE Expressions

This was the part that initially scared me off.

The SP had dozens of embedded rules:

CASE 
    WHEN Status = 1 AND PaymentType = 'X' THEN 'Completed'
    WHEN Status = 2 AND PaymentType = 'Y' THEN 'Pending'
    ...
END AS PaymentStatus

Why this is bad

  • Hard to understand
  • Hard to change
  • Logic is not testable
  • Mixing data access & domain logic

✅ Refactored to C#

PaymentStatus = x.Status switch
{
    1 when x.PaymentType == "X" => "Completed",
    2 when x.PaymentType == "Y" => "Pending",
    _ => "Unknown"
};

Now business rules are readable, testable, and maintainable.

5. Pagination at the End of the SP

The SP collected thousands of rows, inserted them into a table variable, and then applied pagination.


❌ Bad Pattern

SELECT * FROM @temp ORDER BY CreatedOn
OFFSET @skip ROWS FETCH NEXT @take ROWS ONLY;

Why it's horribly inefficient

  • SQL processed far more rows than needed
  • Sorting is expensive
  • No index support on table variables

✅ Correct Pattern (EF Core)

var paged = await _context.Transactions
    .Where(filter)
    .OrderByDescending(x => x.CreatedOn)
    .Skip(skip)
    .Take(take)
    .ToListAsync();

6. Monolithic Stored Procedure

The stored procedure had:

  • 500+ lines
  • Multiple responsibilities
  • Hard-coded rules
  • Nested queries
  • Temporary structures everywhere

These SPs become untouchable over time.


Solution

Break it down:

  • Data access → EF Core
  • Business logic → C# services
  • Formatting → Controller or frontend
  • Heavy read-only queries → Optimized SQL / Dapper (if needed)

7. Missing Index Strategy

Even a perfect query fails without proper indexing.


Problems found:

  • No index on CreatedOn
  • No index on CustomerId
  • No index on TransactionType

This caused:

  • Full table scans
  • Massive tempDB usage
  • Slow joins
  • Random performance degradation

Fix

CREATE INDEX IX_Transactions_CreatedOn 
    ON Transactions(CreatedOn);

CREATE INDEX IX_Transactions_CustomerId 
    ON Transactions(CustomerId);

Results were immediate.

My Refactoring Strategy

I decided on a two-step plan:


Step 1 — Migrate to EF Core Query (if possible)

Most filters and conditions translated well into EF Core. This alone removed table variables, CASE clutter, and pagination issues.


Step 2 — Optimize Stored Procedure & Use Dapper (if needed)

For the few heavy queries that EF couldn't optimize well, I rewrote the SQL cleanly and used Dapper for fast materialization.

Additional Improvements

  • Created proper indexes
  • Added in-memory caching for repeated internal queries
  • Used proper pagination pattern
  • Applied filtering before pagination
  • Used AsNoTracking() everywhere for reporting queries

Final Result — 95% Performance Improvement

After implementing all fixes:

▶ Original API time: ~40 seconds
▶ After refactor: < 2 seconds

A massive 95% improvement.

I was proud.
And the codebase finally became maintainable again.

Conclusion

Legacy reporting stored procedures often grow into slow, over-engineered monsters. But with the right approach, you can turn a 40-second endpoint into a fast, clean API that responds in under two seconds.

Comments

Loading comments...

Vishnu Unnikrishnan | .NET, Flutter & More