📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What was LINQ to SQL, and which of its concepts still matter for a .NET 10 developer using EF Core? Explain the provider model, DataContext and change tracking, identity map, deferred loading, parameterised SQL generation and why LINQ to SQL is not available on modern .NET.

LINQ to SQL as the original IQueryable provider: its architecture and lasting concepts (translation, change tracking, identity map, lazy loading, parameterisation) and how they map to EF Core today.

Mid-Level Theory Intermediate csharp ef-core linq linq-to-sql orm

🎯 Key points

  • LINQ to SQL (System.Data.Linq, .NET Framework 3.5) was Microsoft's first IQueryable provider, SQL Server only, with attribute or XML mapping
  • It translated expression trees into parameterised T-SQL, which is the core concept behind every LINQ provider
  • DataContext tracked changes and applied them with SubmitChanges, like DbContext.SaveChanges
  • Identity map: one entity instance per primary key per context
  • Deferred (lazy) loading of associations caused N+1 queries, the same risk as EF Core lazy-loading proxies
  • Parameterised queries made LINQ safe from SQL injection unless you concatenated raw SQL
  • It is not part of .NET Core or .NET 5+; on .NET 10 use EF Core (or Dapper for hand-written SQL)
  • Migration from LINQ to SQL to EF Core needs attention to translation differences and client evaluation

⏱ 30-second answer

LINQ to SQL was Microsoft's first ORM built on IQueryable, shipped in .NET Framework 3.5 and SQL Server only. You wrote LINQ against a DataContext, and it translated the expression tree into parameterised T-SQL, tracked changes and applied them with SubmitChanges. It never came to .NET Core, so on .NET 10 you use EF Core, but the concepts carry straight over: query translation, change tracking, the identity map, lazy loading and N+1 queries, and parameterisation that prevents SQL injection.

🗣 Full answer (senior)

How I would answer this in an interview

LINQ to SQL (.NET Framework 3.5, 2007) was Microsoft's first LINQ-based ORM for SQL Server: you wrote LINQ against a DataContext, it translated expression trees to SQL, tracked changes and saved them. It was superseded by Entity Framework and isn't available on modern .NET. But almost every concept it introduced lives on in EF Core, so understanding it explains how EF Core works.

1. The provider model

LINQ to SQL implemented IQueryable<T> with its own query provider:

  • Queryable.Where, Select, etc. don't execute anything — they build an expression tree (Expression<Func<T,bool>>).
  • When enumerated, the provider translates the tree to SQL, executes it, and materialises objects.

EF Core uses exactly this model: DbSet<T> is an IQueryable<T>, and EF Core's query pipeline translates expression trees to SQL for SQL Server, PostgreSQL, SQLite, Cosmos DB, etc. The concept that the static type decides where code runs (IQueryable → SQL; IEnumerable → memory) comes straight from this era.

2. DataContext and change tracking

DataContext was the unit of work: it held the connection, the mapping, the identity map and the change tracker. You modified objects and called SubmitChanges(), which generated INSERT/UPDATE/DELETE statements in a transaction.

EF Core's DbContext is the direct descendant:

  • ChangeTracker keeps original values (snapshot change tracking) and detects modifications on SaveChanges().
  • SaveChanges batches commands in a transaction.
  • DbContext is meant to be short-lived (one per request/unit of work) — the same lesson LINQ to SQL users learned when long-lived DataContexts grew huge and stale.

3. Identity map

Within one DataContext, querying the same row twice returned the same object instance. EF Core does the same for tracked queries: one entity instance per key per context. This ensures consistency (changes made through one reference are visible via the other) but costs memory and lookup time — hence EF Core's AsNoTracking() for read-only queries, and AsNoTrackingWithIdentityResolution() to get identity resolution without tracking.

4. Deferred loading

LINQ to SQL lazy-loaded associations by default: accessing customer.Orders triggered a query. That's where the N+1 problem became widely known. DataLoadOptions.LoadWith enabled eager loading.

EF Core: lazy loading is opt-in (proxies or ILazyLoader); the default is explicit eager loading with Include/ThenInclude, explicit loading (Entry(x).Collection(...).LoadAsync()), or (best for reads) projections with Select. The industry learned from LINQ to SQL's N+1 pain.

5. Parameterised SQL generation

LINQ to SQL turned captured variables into SQL parameters (@p0), preventing SQL injection and enabling query plan reuse. EF Core does the same: closure variables become parameters, constants become literals. That's why Where(o => o.CustomerId == customerId) is safe, and why string-concatenated raw SQL (FromSqlRaw($"...")) isn't. EF Core 9/10 added options for parameter handling of collections (e.g. Contains over lists translated with JSON parameters/OPENJSON or multiple parameters) to balance plan caching and performance.

6. Why it's not on modern .NET

  • Microsoft stopped investing in LINQ to SQL around 2008–09 in favour of Entity Framework (multi-database, richer mapping, better architecture).
  • It was SQL Server only, with limited mapping (no complex inheritance strategies, limited value conversions).
  • It was never ported to .NET Core; System.Data.Linq doesn't exist in .NET 5+.
  • Migrating means moving to EF Core (or Dapper for raw SQL), with similar concepts but different APIs (DbContext vs DataContext, SaveChanges vs SubmitChanges, configuration via Fluent API instead of attributes/DBML).

7. A production example

Porting a .NET Framework 4.x system that used LINQ to SQL to .NET 8 required replacing the DBML-generated classes with EF Core entities and configurations. The tricky parts were behaviours the old code relied on implicitly: lazy loading in views (which became N+1 explosions or null navigations in EF Core without lazy loading), a long-lived DataContext cached in a static (replaced by scoped DbContexts), and queries that LINQ to SQL evaluated differently. Logging EF Core SQL (LogTo) and integration tests against SQL Server made the migration safe.

🔍 What the interviewer is checking

Checks historical context and, more importantly, the transferable concepts behind database LINQ.

Green flags: knows it was SQL Server only and .NET Framework only; explains translation, change tracking, identity map, lazy loading and parameterisation; maps each to EF Core.

Red flags: thinks LINQ to SQL is current; cannot explain how a LINQ query becomes SQL.

📚 Learn in depth

1. Query translation pipeline (then and now)

C# lambda → Expression tree → Provider
   LINQ to SQL: expression tree → SQL Server SQL text
   EF Core:     expression tree → query model (relational expressions) → provider-specific SQL → cached compiled query

EF Core adds a query cache keyed by expression tree shape: the first execution translates and compiles; later executions with different parameter values reuse it. Changing constants (rather than parameters) produces different cache entries — a reason to avoid building queries with inline constants dynamically.

2. Change tracking strategies

  • Snapshot (default in both): keep original values, compare on save. Cost: memory and comparison time proportional to tracked entities.
  • Notification-based (EF Core supports INotifyPropertyChanged/proxies): entities notify the tracker of changes.
  • EF Core 7+ ExecuteUpdate/ExecuteDelete perform set-based updates without tracking — something LINQ to SQL couldn't do natively.

3. Identity map behaviour details

  • Tracked queries return existing instances when the key is already tracked — even if the database row changed, unless you re-query with tracking reset (or call Reload()).
  • This can surprise developers in long-lived contexts (stale data). Short-lived contexts avoid it.
  • AsNoTracking() creates new instances per row; AsNoTrackingWithIdentityResolution() ensures one instance per key within the query results only.

4. Client evaluation history

LINQ to SQL (and EF Core 1–2) silently evaluated untranslatable parts in memory. EF Core 3.0 changed this: client evaluation is only allowed in the final projection; anything else untranslatable throws. Lesson learned: silent client evaluation caused huge hidden performance problems.

5. Mapping approaches

LINQ to SQL EF Core
DBML designer / [Table], [Column] attributes Conventions, data annotations, Fluent API (IEntityTypeConfiguration<T>)
SQL Server only Many providers
Single-table inheritance only TPH, TPT, TPC
Limited type conversions Value converters, owned/complex types, JSON columns
SubmitChanges SaveChanges/SaveChangesAsync, bulk ExecuteUpdate/Delete

Try it yourself

  1. Enable EF Core SQL logging (optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information)) and run a query with a captured variable; observe parameters.
  2. Query the same entity twice in one DbContext with tracking and compare references; then with AsNoTracking.
  3. Enable lazy loading proxies and iterate customers' orders to reproduce N+1, then fix with Include or a projection.
  4. Use ExecuteUpdateAsync for a bulk status change and compare the SQL with tracked updates.

💻 Code

// Legacy LINQ to SQL (.NET Framework 3.5-4.8 only), shown for recognition:
// [Table(Name = "Orders")] public class Order { [Column(IsPrimaryKey = true)] public int Id; ... }
// using var db = new DataContext(connectionString);
// var big = db.GetTable<Order>().Where(o => o.Total > 100).ToList();
// db.SubmitChanges();

// The same ideas in EF Core 10 on .NET 10
using Microsoft.EntityFrameworkCore;

namespace Linq.ToSqlConcepts;

public sealed class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public List<Order> Orders { get; } = [];
}

public sealed class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public decimal Total { get; set; }
    public Customer Customer { get; set; } = null!;
}

public sealed class SalesContext(DbContextOptions<SalesContext> options) : DbContext(options)
{
    public DbSet<Customer> Customers => Set<Customer>();
    public DbSet<Order> Orders => Set<Order>();
}

public static class Queries
{
    // Translation to parameterised SQL
    public static IQueryable<Order> LargeOrders(SalesContext db, decimal minimum)
        => db.Orders.Where(o => o.Total > minimum);

    // Eager projection instead of lazy loading (avoids N+1)
    public static Task<List<OrderSummary>> SummariesAsync(SalesContext db, CancellationToken ct)
        => db.Orders
             .AsNoTracking()
             .Select(o => new OrderSummary(o.Id, o.Customer.Name, o.Total))
             .ToListAsync(ct);

    // Parameterised raw SQL: interpolation becomes DbParameter, not string concatenation
    public static IQueryable<Customer> ByName(SalesContext db, string name)
        => db.Customers.FromSql($"SELECT * FROM Customers WHERE Name = {name}");
}

public sealed record OrderSummary(int Id, string CustomerName, decimal Total);

// Usage (for example in a test):
// Console.WriteLine(Queries.LargeOrders(db, 100m).ToQueryString());
// -- DECLARE @minimum decimal(18,2) = 100.0;
// -- SELECT ... FROM [Orders] AS [o] WHERE [o].[Total] > @minimum

⚠ Common mistakes

1. Treating EF Core as a different concept from LINQ to SQL

What happens: candidates can't explain where EF Core's behaviours come from. Why it is wrong: misses the shared provider model, change tracking and identity map ideas. Do instead: explain the lineage and what EF Core improved.

2. Long-lived contexts

What happens: a DbContext (like old DataContexts) kept as a singleton. Why it is wrong: not thread-safe, grows indefinitely, returns stale tracked data. Do instead: scoped, short-lived contexts (or IDbContextFactory for background work).

3. Relying on lazy loading

What happens: ported code expects navigation properties to load on access. Why it is wrong: N+1 queries or null navigations in EF Core. Do instead: explicit Include or projections.

4. Tracking read-only queries

What happens: large read queries tracked by default. Why it is wrong: memory and CPU overhead for change tracking that's never used. Do instead: AsNoTracking() for reads.

5. Building SQL strings

What happens: concatenating user input into raw SQL. Why it is wrong: SQL injection; no plan reuse. Do instead: LINQ or parameterised FromSql/ExecuteSql with interpolation.

6. Expecting silent client evaluation

What happens: untranslatable methods in Where clauses. Why it is wrong: EF Core 3+ throws instead of evaluating in memory. Do instead: rewrite to translatable expressions or evaluate explicitly after materialising.

7. Assuming LINQ to SQL can be used on .NET 10

What happens: trying to reference System.Data.Linq. Why it is wrong: not available on modern .NET. Do instead: migrate to EF Core or Dapper.

8. Ignoring the identity map's staleness

What happens: re-querying in the same context expecting fresh values. Why it is wrong: tracked instances may not be refreshed. Do instead: use new contexts, AsNoTracking, or Reload().

🏛 Senior insight: trade-offs

The value of this question is the concept, not the retired library. A senior can explain the provider model (expression tree in, parameterised SQL out), the unit-of-work and identity-map behaviour of a context, and the classic failure modes (N+1, hidden client evaluation, unbounded tracking). I also mention practical migration experience: modern EF Core is stricter, which is a feature, and every migrated query should be verified against the real database.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate maps DataContext to DbContext. Why: Moves from the retired library to how EF Core does the same job today.
    How to answer

    In EF Core, DbSet<T> is an IQueryable<T>, so Where, Select and so on build an expression tree, and EF Core translates it to SQL when you enumerate, caching the translation by query shape. Only what EF understands translates, like properties, operators and many string, date and EF.Functions methods. Since EF Core 3.0, client evaluation is allowed only in the final Select, so anything else untranslatable throws. For reads I use AsNoTracking or projections, AsSplitQuery when collection includes explode, and I check the SQL with ToQueryString(), logging and Query Store.

  2. When: Candidate mentions expression trees translated to SQL. Why: The provider model is the concept behind every database LINQ library.
    How to answer

    IQueryable<T> is an IEnumerable<T> that also carries an expression tree and a provider. Queryable.Where takes an Expression<Func<T,bool>> and just wraps the expression, and when you enumerate, the provider, for example EF Core, translates the whole tree, typically into SQL. IEnumerable operators take delegates and run in memory. Which one you get depends on the static type of the variable, so storing a DbSet in an IEnumerable variable quietly moves every later Where into memory. AsEnumerable() does that on purpose. I keep IQueryable inside the data layer and return materialised results.

🛠 Real-world problems: how you handle them

Hidden client evaluation surfaced during a LINQ to SQL migration
Situation
Migrating a .NET Framework 4.8 insurance claims system from LINQ to SQL to EF Core 10 on .NET 10.
Signals
Several report queries threw 'could not be translated' in EF Core; in the old system they had worked but were slow (30+ seconds).
Investigation
Captured the old SQL with SQL Server Profiler and saw the legacy provider selected whole tables and filtered in memory because a custom C# method was used in Where.
Resolution
Rewrote the filters as translatable expressions (computed columns and simple comparisons) and verified the SQL with ToQueryString and Query Store.
Prevention
Added integration tests that run every repository query against a SQL Server container and fail on client evaluation warnings.

How to tell it (STAR)

**S:** We were moving a claims platform from .NET Framework to .NET 10. **T:** I owned the data access migration. **A:** EF Core rejected queries the old LINQ to SQL provider had silently run in memory; I traced the generated SQL, rewrote the filters to translate, and added tests against a real SQL Server. **R:** Two key reports went from [time] to [time], and the migration finished without data access regressions.

More in LINQ

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me