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.
