Modernizing a 15-Year-Old .NET System Without Breaking Production (Part 2)

Moving from EF to Dapper in a Legacy .NET System (Without a Rewrite)

Series: Modernizing a 15-Year-Old .NET System Without Breaking Production
Part 2 of 7


Most discussions about Entity Framework vs Dapper assume a greenfield project.
In reality, many teams operate large, long-lived systems where EF has been deeply embedded for years.

This post is not about replacing EF.
It’s about introducing Dapper safely, in a 15-year-old production system, without breaking behavior, rewriting entities, or destabilizing delivery.

1. EF Was Not a Mistake — Until It Became a Constraint

Entity Framework was the right choice when the system was smaller:

  • Faster development
  • Clear domain models
  • Acceptable performance
  • Easier onboarding

Over time, the system evolved:

  • Data volume increased significantly
  • Queries became more complex
  • Reporting and analytics grew
  • Performance expectations tightened

EF didn’t suddenly become “bad”.
It became less transparent.

Typical Warning Signs

We started seeing:

  • LINQ queries producing unpredictable SQL
  • Minor code changes causing major query regressions
  • Heavy use of Include() leading to cartesian explosions
  • TempDB spills in production
  • Difficulties reasoning about execution plans

At that point, performance tuning through EF became guesswork.

2. The Critical Decision: Why We Didn’t Replace EF

The obvious question came up:

> “Should we migrate everything to Dapper?”

That would have been a mistake.

Why a Full Migration Was Rejected

  • Thousands of existing EF queries
  • Complex change tracking logic
  • Business rules embedded in entities
  • High regression risk
  • No clear ROI for rewriting stable code

More importantly: not all queries were slow.

We didn’t need a new ORM.
We needed control where it mattered.

3. Identifying the “Hot Paths”

Instead of guessing, we focused on hot paths:

  • High-traffic API endpoints
  • Reports used daily by operations
  • Queries that appeared repeatedly in slow query logs

Practical Signals We Used

  • SQL Server execution plans
  • TempDB usage
  • Query duration spikes
  • High CPU queries with low row counts
  • Repeated complaints from users (“this screen is slow”)

Only read-heavy, performance-critical paths were candidates.

This is crucial: > If everything is slow, you have a bigger problem.
> If some things are slow, optimize surgically.

4. Letting EF and Dapper Coexist

Instead of “EF vs Dapper”, we adopted: > EF + Dapper

The core principle:

  • EF handles all writes (INSERT, UPDATE, DELETE)
  • Dapper handles performance-critical reads (SELECT queries)

This is essentially a Command-Query separation at the data access level:

  • Commands (state changes) → EF with change tracking
  • Queries (read operations) → Dapper with explicit SQL

No rewrite. No schema changes. No dual maintenance of write logic.

5. Reusing DbContext Connections (Key Technique)

One of the biggest mistakes teams make is treating Dapper as a separate data layer.

We didn’t.

The Rule

> Use the same database connection that EF uses.

This avoids:

  • Duplicate connection management
  • Transaction boundary issues
  • Configuration drift

Example

using var connection = dbContext.Database.GetDbConnection();

var result = await connection.QueryAsync(
    sql,
    new { visitId }
);

Benefits:

  • EF still controls connection lifetime
  • Works inside existing transactions
  • Minimal architectural disruption

6. Keeping Existing Entities (No Model Explosion)

Another common mistake: > “Let’s create new DTOs for Dapper.”

We deliberately did not.

What We Did Instead

  • Reused existing EF entities as read models
  • Ignored navigation properties
  • Used Dapper strictly for projection

Example:

SELECT 
    StockMoveId,
    VisitId,
    Quantity,
    CreatedDate
FROM StockMoves
WHERE VisitId = @visitId

Mapped directly to:

public class StockMove
{
    public long StockMoveId { get; set; }
    public long VisitId { get; set; }
    public int Quantity { get; set; }
    public DateTime CreatedDate { get; set; }
}

Why This Matters

  • No duplication of domain language
  • Lower cognitive load
  • Easy rollback to EF if needed
  • Business logic remains unchanged
  • Clear separation: EF entities still handle writes, Dapper just reads into them

7. Transaction Safety with EF + Dapper

This is where many integrations fail.

Rule of Thumb

  • EF manages transactions
  • EF performs all writes (INSERT/UPDATE/DELETE)
  • Dapper only queries within the transaction scope

Example:

using var transaction = await dbContext.Database.BeginTransactionAsync();

// Dapper: Read data for validation
var existingRecords = await connection.QueryAsync(
    "SELECT * FROM Records WHERE CustomerId = @id",
    new { id = customerId }
);

// EF: Perform business logic and writes
var newOrder = new Order { CustomerId = customerId, ... };
dbContext.Orders.Add(newOrder);
await dbContext.SaveChangesAsync();

// Dapper: Read updated state if needed for response
var summary = await connection.QuerySingleAsync(
    "SELECT OrderId, TotalAmount FROM Orders WHERE OrderId = @id",
    new { id = newOrder.OrderId }
);

await transaction.CommitAsync();

This ensures:

  • Atomic behavior
  • EF maintains write consistency and change tracking
  • Dapper provides fast reads within the same transaction
  • No partial writes or state conflicts

8. SQL Ownership Comes Back (And That’s a Good Thing)

Using Dapper forced us to:

  • Read execution plans again
  • Understand indexes
  • Think about join order
  • Be explicit about projections

This was uncomfortable at first — then empowering.

EF Hides Too Much

EF makes it easy to:

  • Over-fetch data
  • Accidentally create N+1 queries
  • Miss index usage
  • Ignore query shape

Dapper makes SQL visible again.

And visibility is what allows optimization.

9. Performance Results (What Actually Improved)

We didn’t see miracles everywhere — and that’s important.

Where Dapper helped:

  • Complex read-only queries
  • Aggregation-heavy reports
  • Queries with many joins
  • Screens with tight latency requirements

Where EF stayed:

  • CRUD operations
  • Write workflows
  • Business rule enforcement

Performance gains ranged from:

  • 30–70% reduction in query time
  • Much more predictable behavior
  • Easier diagnosis when things slowed down

10. Anti-Patterns to Avoid (Learned the Hard Way)

❌ Dapper Everywhere

This creates:

  • Two mental models for everything
  • Loss of EF benefits (change tracking, validation)
  • Harder onboarding

❌ Writing with Dapper (INSERT/UPDATE/DELETE)

This is the worst mistake:

  • Bypasses EF change tracking
  • Loses business rule enforcement
  • Creates stale entities in memory
  • Breaks domain model integrity
  • Makes debugging nearly impossible

Golden Rule: If you’re modifying data, use EF. Always.

❌ Using EF for Queries Already Optimized with Dapper

Once you’ve moved a query to Dapper for performance:

  • Don’t fall back to EF for the same data
  • Keep the decision boundary clear
  • Document which queries use which approach

❌ Duplicating Entities

DTO explosion kills maintainability faster than slow queries.

11. Decision Checklist (Use This)

Always use EF for:

  • INSERT operations
  • UPDATE operations
  • DELETE operations
  • Any state change that requires business rules
  • Workflows with change tracking
  • Domain model integrity

Consider Dapper for:

  • SELECT queries that are performance-critical
  • Complex reporting queries
  • Read-heavy API endpoints
  • Scenarios where you need explicit SQL control
  • Queries with specific indexing requirements
  • Aggregations and analytics

Stay with EF even for reads when:

  • Performance is already acceptable
  • Query complexity is low
  • Navigation properties provide value
  • Team is unfamiliar with SQL tuning

Do nothing when:

  • The problem is not measured
  • The code is not hot
  • Risk outweighs gain

Final Thoughts

This approach worked because it respected reality:

  • The system was old
  • The business needed stability
  • Performance mattered — selectively

We didn’t “move from EF to Dapper”.

We expanded our toolbox with a clear division of labor:

  • EF = Command side (all writes)
  • Dapper = Query side (performance-critical reads)

This boundary is clear, teachable, and sustainable.

That mindset — pragmatic, incremental, reversible — is the difference between modernization and disruption.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top