ADO.NET, LINQ, and EF: A Comprehensive Guide with Interview Question

ADO.NET, LINQ, and EF: A Comprehensive Guide with Interview Question

Introduction

Data access in .NET has evolved significantly, from the raw ADO.NET approach to modern ORM solutions like Entity Framework Core (EF Core). In this article, we will explore the history, features, and best practices of ADO.NET, LINQ, and EF Core, including deep dives into core concepts like Code-First, DbContext lifecycle, entity tracking, interceptors, TPT, TPH, TCP inheritance, lazy loading, navigation properties, joins, groupings, and more.

ADO.NET: The Foundation

Before ORM solutions, ADO.NET was the primary way to interact with databases in .NET. It provides low-level access to data via Connections, Commands, DataReaders, and DataAdapters.

Example: ADO.NET Connection & Query Execution

using System;
using System.Data.SqlClient;

class Program
{
    static void Main()
    {
        string connectionString = "your_connection_string";
        using (SqlConnection conn = new SqlConnection(connectionString))
        {
            conn.Open();
            SqlCommand cmd = new SqlCommand("SELECT * FROM Students", conn);
            SqlDataReader reader = cmd.ExecuteReader();

            while (reader.Read())
            {
                Console.WriteLine($"ID: {reader["Id"]}, Name: {reader["Name"]}");
            }
        }
    }
}

Drawbacks of ADO.NET

  • Requires manual handling of SQL queries.

  • No built-in change tracking.

  • Difficult to work with complex relationships.

LINQ (Language Integrated Query)

LINQ simplifies querying by providing a type-safe, SQL-like syntax integrated into C#.

Example: LINQ to Objects

List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
var evenNumbers = numbers.Where(n => n % 2 == 0).ToList();

foreach (var num in evenNumbers)
{
    Console.WriteLine(num);
}

Example: LINQ to SQL

using (var context = new SchoolContext())
{
    var students = context.Students.Where(s => s.Age > 18).ToList();
    students.ForEach(s => Console.WriteLine(s.Name));
}

Limitations of LINQ to SQL:

  • Works only with SQL Server.

  • Limited support for complex relationships.

Entity Framework Core (EF Core)

EF Core is an ORM that abstracts database interactions, enabling developers to use Code-First or Database-First approaches.

Code-First Approach

With Code-First, you define your models in C#, and EF Core generates the database schema.

public class Student
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Age { get; set; }
}

public class SchoolContext : DbContext
{
    public DbSet<Student> Students { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer("your_connection_string");
    }
}

Migrations & Snapshot Creation

Run the following commands to create migrations:

dotnet ef migrations add InitialCreate
dotnet ef database update

DbContext Lifecycle & Entity Tracking

The DbContext in EF Core is responsible for tracking entity changes.

DbContext States

  • Unchanged: No modifications.

  • Added: Inserted but not yet saved.

  • Modified: Updated but not yet saved.

  • Deleted: Marked for deletion.

Example: Entity Tracking

using (var context = new SchoolContext())
{
    var student = context.Students.FirstOrDefault(s => s.Id == 1);
    student.Name = "Updated Name";

    Console.WriteLine(context.Entry(student).State); // Modified
    context.SaveChanges();
}

Interceptors in EF Core

EF Core interceptors allow you to log or modify SQL queries dynamically.

Example: Logging SQL Queries

public class QueryInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<int> NonQueryExecuting(
        DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
    {
        Console.WriteLine($"Executing SQL: {command.CommandText}");
        return result;
    }
}

Register it in DbContext:

optionsBuilder.AddInterceptors(new QueryInterceptor());

Entity Relationships & Navigation Properties

Types of Relationships

  • One-to-One

    •   public class Student
        {
            public int Id { get; set; }
            public string Name { get; set; }
      
            public StudentProfile StudentProfile { get; set; } // Navigation Property
        }
      
        public class StudentProfile
        {
            public int Id { get; set; }
            public string Address { get; set; }
            public DateTime DateOfBirth { get; set; }
      
            public int StudentId { get; set; }  // Foreign Key
            public Student Student { get; set; }  // Navigation Property
        }
      
    •   protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Student>()
                .HasOne(s => s.StudentProfile)
                .WithOne(sp => sp.Student)
                .HasForeignKey<StudentProfile>(sp => sp.StudentId);
        }
      
  • One-to-Many

    •   public class Class
        {
            public int Id { get; set; }
            public string ClassName { get; set; }
      
            public ICollection<Student> Students { get; set; } = new List<Student>(); // Navigation Property
        }
      
        public class Student
        {
            public int Id { get; set; }
            public string Name { get; set; }
      
            public int ClassId { get; set; } // Foreign Key
            public Class Class { get; set; } // Navigation Property
        }
      
    •   protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Student>()
                .HasOne(s => s.Class)
                .WithMany(c => c.Students)
                .HasForeignKey(s => s.ClassId);
        }
      
  • Many-to-Many

    •   public class Student
        {
            public int Id { get; set; }
            public string Name { get; set; }
      
            public ICollection<StudentCourse> StudentCourses { get; set; } = new List<StudentCourse>(); // Navigation Property
        }
      
        public class Course
        {
            public int Id { get; set; }
            public string CourseName { get; set; }
      
            public ICollection<StudentCourse> StudentCourses { get; set; } = new List<StudentCourse>(); // Navigation Property
        }
      
        // Junction Table (Many-to-Many)
        public class StudentCourse
        {
            public int StudentId { get; set; }
            public Student Student { get; set; }
      
            public int CourseId { get; set; }
            public Course Course { get; set; }
        }
      
    •   protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<StudentCourse>()
                .HasKey(sc => new { sc.StudentId, sc.CourseId }); // Composite Key
      
            modelBuilder.Entity<StudentCourse>()
                .HasOne(sc => sc.Student)
                .WithMany(s => s.StudentCourses)
                .HasForeignKey(sc => sc.StudentId);
      
            modelBuilder.Entity<StudentCourse>()
                .HasOne(sc => sc.Course)
                .WithMany(c => c.StudentCourses)
                .HasForeignKey(sc => sc.CourseId);
        }
      

Querying Relationships in LINQ

  • One-to-One Query

    •   var studentWithProfile = context.Students
            .Include(s => s.StudentProfile)
            .FirstOrDefault(s => s.Id == 1);
      
  • One-to-Many Query

    •   var classWithStudents = context.Classes
            .Include(c => c.Students)
            .FirstOrDefault(c => c.Id == 1);
      
  • Many-to-Many Query

    •   var studentCourses = context.Students
            .Include(s => s.StudentCourses)
            .ThenInclude(sc => sc.Course)
            .FirstOrDefault(s => s.Id == 1);
      

Inheritance Strategies

EF Core supports Table Per Type (TPT), Table Per Hierarchy (TPH), and Table Per Concrete Class (TPC).

TPH (Table Per Hierarchy)

Stores all entities in a single table with a discriminator column.

public class Person { 
public int Id { get; set; } 
public string Name { get; set; } 
}
public class Teacher : Person { public string Subject { get; set; } }
public class Student : Person { public int Grade { get; set; } }

TPT (Table Per Type)

Each entity gets its own table.

modelBuilder.Entity<Teacher>().ToTable("Teachers");
modelBuilder.Entity<Student>().ToTable("Students");

TPC (Table Per Concrete Class)

Each derived entity is stored in its own table without a base table.

StrategyDescriptionProsCons
TPH (Table Per Hierarchy)Single table with a Discriminator columnBest performanceWastes space (null columns)
TPT (Table Per Type)Separate table for each classNormalizedMore joins (slower)
TPC (Table Per Concrete Class)Each class gets a separate tableNo nulls, no joinsHarder to query

Loading Strategies

  • Eager Loading: Loads related entities immediately.

  • Lazy Loading: Loads related entities only when accessed.

  • Explicit Loading: Loads related data manually.

Eager Loading

Eager loading is when related entities are loaded at the same time as the main entity using the Include() and ThenInclude() methods. This reduces the number of queries by fetching everything in a single database call.

public class Student
{
    public int Id { get; set; }
    public string Name { get; set; }

    public int ClassId { get; set; }  // Foreign key for Class
    public Class Class { get; set; }  // Reference navigation property

    public ICollection<Course> Courses { get; set; } = new List<Course>(); // Collection navigation property
}

public class Course
{
    public int Id { get; set; }
    public string CourseName { get; set; }

    public ICollection<Student> Students { get; set; } = new List<Student>();
}

public class Class
{
    public int Id { get; set; }
    public string ClassName { get; set; }

    public ICollection<Student> Students { get; set; } = new List<Student>();
}
using (var context = new SchoolContext(options))
{
    // Fetch students along with their Class and Courses in a single query
    var students = context.Students
        .Include(s => s.Class)   // Eager load the Class entity
        .Include(s => s.Courses) // Eager load the Courses collection
        .ToList();

    foreach (var student in students)
    {
        Console.WriteLine($"Student: {student.Name}, Class: {student.Class.ClassName}");

        foreach (var course in student.Courses)
        {
            Console.WriteLine($"Enrolled in: {course.CourseName}");
        }
    }
}

Lazy Loading

Lazy loading in Entity Framework Core allows related entities to be loaded automatically when they are accessed for the first time. Here's a full example demonstrating lazy loading using UseLazyLoadingProxies() in an ASP.NET Core application

Requires enabling proxy generation:

builder.Services.AddDbContext<SchoolContext>(options => 
    options.UseSqlServer(connectionString)
           .UseLazyLoadingProxies());
using (var context = new SchoolContext(options))
{
    var student = context.Students.FirstOrDefault(s => s.Id == 1);

    // At this point, Courses are not yet loaded.
    Console.WriteLine("Student fetched!");

    // Lazy loading happens when accessing the navigation property.
    foreach (var course in student.Courses)
    {
        Console.WriteLine($"Enrolled in: {course.CourseName}");
    }
}

Explicit Loading

Explicit loading in Entity Framework Core is a technique where you manually load related entities instead of relying on lazy loading. This is useful when lazy loading is disabled or when you want precise control over when related data is fetched.

public class Student
{
    public int Id { get; set; }
    public string Name { get; set; }

    // No 'virtual' keyword, as explicit loading does not rely on proxies
    public ICollection<Course> Courses { get; set; } = new List<Course>();
}

public class Course
{
    public int Id { get; set; }
    public string CourseName { get; set; }

    public ICollection<Student> Students { get; set; } = new List<Student>();
}
using (var context = new SchoolContext(options))
{
    // Fetch a student without loading related courses
    var student = context.Students.FirstOrDefault(s => s.Id == 1);

    if (student != null)
    {
        Console.WriteLine("Student fetched!");

        // Explicitly load the Courses collection
        context.Entry(student).Collection(s => s.Courses).Load();

        // Now the courses are loaded
        foreach (var course in student.Courses)
        {
            Console.WriteLine($"Enrolled in: {course.CourseName}");
        }
    }
}

Comparison with Lazy, Eager & Explicit Loading

FeatureLazy LoadingExplicit LoadingEager Loading
ConfigurationRequires UseLazyLoadingProxies()Uses .Entry().Load()Uses .Include()
Query CountMultiple queries (when accessing navigation properties)Multiple queries (when calling .Load())Single query
PerformanceCan cause N+1 problemControlled loadingMore efficient but may load unnecessary data
Use CaseAutomatic loading when accessedPrecise control over related data loadingWhen you need related data immediately

Joins & Grouping

Inner Join

var result = from s in context.Students
             join c in context.Courses on s.CourseId equals c.Id
             select new { s.Name, Course = c.Name };

Grouping

var groups = context.Students
    .GroupBy(s => s.CourseId)
    .Select(g => new { CourseId = g.Key, Count = g.Count() })
    .ToList();

Left Join (GroupJoin)

var result = from c in context.Courses
             join s in context.Students on c.Id equals s.CourseId into studentGroup
             from sg in studentGroup.DefaultIfEmpty()
             select new { Course = c.Name, Student = sg?.Name ?? "No Student" };

Nested Grouping

Want to group students by class and then group their courses within each class.

var groupedData = from student in context.Students
                  group student by student.Class.ClassName into classGroup  // First Level: Group by Class
                  select new
                  {
                      ClassName = classGroup.Key,
                      Students = from student in classGroup
                                 group student by student.Name into studentGroup  // Second Level: Group by Student
                                 select new
                                 {
                                     StudentName = studentGroup.Key,
                                     Courses = studentGroup.SelectMany(s => s.Courses)
                                                           .Select(c => c.CourseName)
                                                           .Distinct()
                                 }
                  };

// Display results
foreach (var classGroup in groupedData)
{
    Console.WriteLine($"Class: {classGroup.ClassName}");

    foreach (var studentGroup in classGroup.Students)
    {
        Console.WriteLine($"  Student: {studentGroup.StudentName}");

        foreach (var course in studentGroup.Courses)
        {
            Console.WriteLine($"    Course: {course}");
        }
    }
}
  • When dealing with hierarchical data (e.g., Categories → Products, Departments → Employees).

  • When you need multi-level aggregations.

Using let to Calculate a Derived Value

Let's say we have a Student entity with FirstName and LastName. We want to retrieve students with a full name in uppercase.

var studentsWithFullName = from student in context.Students
                           let fullName = student.FirstName + " " + student.LastName // Define temporary variable
                           select new
                           {
                               StudentId = student.Id,
                               FullName = fullName.ToUpper() // Use the `let` variable
                           };

foreach (var student in studentsWithFullName)
{
    Console.WriteLine($"ID: {student.StudentId}, Name: {student.FullName}");
}

Using let in Filtering

Let's find students enrolled in at least 2 courses

var studentsInMultipleCourses = from student in context.Students
                                let courseCount = student.Courses.Count()
                                where courseCount >= 2
                                select new
                                {
                                    student.Name,
                                    EnrolledCourses = courseCount
                                };

foreach (var student in studentsInMultipleCourses)
{
    Console.WriteLine($"{student.Name} is enrolled in {student.EnrolledCourses} courses.");
}

Using let for Nested Queries

Let's say we want to retrieve students who are enrolled in any course with "Math" in its name.

var mathStudents = from student in context.Students
                   let mathCourses = student.Courses.Where(c => c.CourseName.Contains("Math"))
                   where mathCourses.Any() // Filter students who have at least one Math course
                   select new
                   {
                       student.Name,
                       Courses = mathCourses.ToList()
                   };

foreach (var student in mathStudents)
{
    Console.WriteLine($"{student.Name} is taking:");

    foreach (var course in student.Courses)
    {
        Console.WriteLine($" - {course.CourseName}");
    }
}

Query vs. Method Syntax

// Query Syntax
var students = from s in context.Students
               where s.Age > 18
               select s;

// Method Syntax
var studentsMethod = context.Students.Where(s => s.Age > 18).ToList();

Deferred Execution

Queries are not executed immediately but when iterated over.

var students = context.Students.Where(s => s.Age > 18); // Query is not executed yet
var list = students.ToList(); // Execution happens here

Immediate Execution

To force immediate execution, use methods like: ToList(), ToArray(), Count(), FirstOrDefault()

var studentCount = context.Students.Count(); // Query executes immediately

Aggregation

var studentCount = context.Students.Count();
var maxAge = context.Students.Max(s => s.Age);
var avgAge = context.Students.Average(s => s.Age);

Partitioning

var first5Students = context.Students.Take(5).ToList();
var skipFirst5 = context.Students.Skip(5).ToList();

Set Operations(Distinct, Union, Intersect, Except)

var distinctAges = context.Students.Select(s => s.Age).Distinct().ToList();
var commonAges = context.Students.Select(s => s.Age)
                 .Intersect(context.Teachers.Select(t => t.Age))
                 .ToList();

Fluent API vs Data Annotations

FeatureFluent APIData Annotations
ConfigurationmodelBuilder.Entity<Student>()[Required]
RelationshipsHasOne().WithMany()
IndexingHasIndex(x => x.Name)[Index] (Not supported in EF Core)

Example: Fluent API Configuration

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Student>()
        .Property(s => s.Name)
        .IsRequired();
}

Change Tracking & Entity States

EF Core automatically tracks entity states: Unchanged, Added, Modified, Deleted

var student = context.Students.First();
student.Name = "Updated Name";
Console.WriteLine(context.Entry(student).State); // Modified
context.SaveChanges();

Bulk Operations(ExecuteUpdate, ExecuteDelete)

context.Students.Where(s => s.Age < 15).ExecuteDelete();
context.Students.Where(s => s.Age > 18).ExecuteUpdate(s => s.SetProperty(s => s.Age, s => s.Age + 1));

Transactions

using var transaction = context.Database.BeginTransaction();
context.Students.Add(new Student { Name = "John" });
context.SaveChanges();
transaction.Commit();

Compiled Queries for Performance

private static readonly Func<SchoolContext, int, Student> GetStudentById =
    EF.CompileQuery((SchoolContext context, int id) => context.Students.First(s => s.Id == id));

EF Core has evolved to provide a powerful and flexible ORM solution with features like interceptors, navigation properties, entity tracking, lazy loading, and inheritance strategies. Mastering these concepts will help you ace technical interviews and build robust applications.

Questions You Might Face in interview

How does EF Core track entity changes?

EF Core tracks entity changes using the Change Tracker mechanism. Every time an entity is retrieved, EF Core maintains its original values and monitors modifications

Entity States in EF Core:

StateDescription
UnchangedNo modifications detected.
AddedEntity is new and will be inserted.
ModifiedEntity is updated.
DeletedEntity is removed from the database.
DetachedEntity is not being tracked.

Example: Checking Change Tracking

var student = context.Students.FirstOrDefault(s => s.Id == 1);
student.Name = "Updated Name"; 

Console.WriteLine(context.Entry(student).State); // Outputs: Modified
context.SaveChanges();

How to Disable Tracking?

Use .AsNoTracking() to improve performance for read-only queries.

var students = context.Students.AsNoTracking().ToList();

What are shadow properties in EF Core?

Shadow properties exist in the EF Core model but not in the C# class. They are useful for tracking extra data without modifying the entity.

Example: Adding a Shadow Property

modelBuilder.Entity<Student>()
    .Property<DateTime>("CreatedAt")
    .HasDefaultValueSql("GETDATE()");

Example: Accessing a Shadow Property

var createdAt = context.Entry(student).Property("CreatedAt").CurrentValue;

Useful for auditing (CreatedAt, UpdatedAt) without modifying entity classes.

How would you handle multi-tenancy in EF Core?

Multi-tenancy means supporting multiple clients (tenants) with isolated data.

Approaches:

  • Schema-based (Separate DB Schema per tenant)

  • Database-based (Separate DB per tenant)

  • Row-based (Single DB, tenant column in tables)

Example: Global Query Filters for Multi-Tenancy

modelBuilder.Entity<Order>()
    .HasQueryFilter(o => o.TenantId == _currentTenantId);
  • Ensures tenants only access their data.

  • Works well for row-based multi-tenancy.

How does AsNoTracking() affect performance?

AsNoTracking() disables tracking, making queries faster for read-only operations.

Example: With Tracking (Slower)

var students = context.Students.ToList(); // Tracked

Example: Without Tracking (Faster)

var students = context.Students.AsNoTracking().ToList(); // Not tracked
  • Use for reporting and read-only queries.

  • Do NOT use if updating entities.

How do you optimize bulk insert/update operations?

Use AddRange() for inserts.

context.Students.AddRange(new Student { Name = "John" }, new Student { Name = "Jane" });
context.SaveChanges();

Use ExecuteUpdate() for bulk updates (EF Core 7+)

context.Students.Where(s => s.Age > 18)
    .ExecuteUpdate(s => s.SetProperty(s => s.Age, s => s.Age + 1));

Much faster than looping and updating individually.

How do you handle concurrency conflicts in EF Core?

EF Core optimistic concurrency control prevents data overwrites.

Using a Row Version Column

public class Student
{
    public int Id { get; set; }
    public string Name { get; set; }
    [Timestamp] // Marks as concurrency token
    public byte[] RowVersion { get; set; }
}

Handling Conflicts

try
{
    context.SaveChanges();
}
catch (DbUpdateConcurrencyException ex)
{
    Console.WriteLine("Concurrency conflict detected!");
}

How to Add a Database View in DbContext and Query It in EF Core

CREATE VIEW vw_StudentDetails AS
SELECT s.Id AS StudentId, s.Name AS StudentName, c.ClassName
FROM Students s
JOIN Classes c ON s.ClassId = c.Id;

Create a C# Model for the View

public class StudentDetailView
{
    public int StudentId { get; set; }
    public string StudentName { get; set; }
    public string ClassName { get; set; }
}

Add the View to DbContext

public class SchoolContext : DbContext
{
    public SchoolContext(DbContextOptions<SchoolContext> options) : base(options) { }

    public DbSet<Student> Students { get; set; }
    public DbSet<Class> Classes { get; set; }

    // Add the view
    public DbSet<StudentDetailView> StudentDetails { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // Configure the view as a table mapping but make it read-only
        modelBuilder.Entity<StudentDetailView>().ToView("vw_StudentDetails").HasKey(s => s.StudentId);
    }
}

Query the View in LINQ

using (var context = new SchoolContext(options))
{
    var studentDetails = context.StudentDetails.ToList();

    foreach (var student in studentDetails)
    {
        Console.WriteLine($"ID: {student.StudentId}, Name: {student.StudentName}, Class: {student.ClassName}");
    }
}

Filtering and Aggregation with LINQ

var studentsInClassA = context.StudentDetails
    .Where(s => s.ClassName == "Class A")
    .OrderBy(s => s.StudentName)
    .ToList();