EF Core 10 Relational Data · Lesson 5

Relationships and enforced foreign keys

Model a required relationship and verify the relational constraint.

External net10.0 console; SDK10.0.403; Microsoft.EntityFrameworkCore.Sqlite 10.0.14; native SQLite relational provider. Portable practice is a separate .NET9 BCL-only program.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

Object navigation and relational identity must agree

A post’s Blog reference lets application code traverse an object graph, while BlogId records the related principal key. The model explicitly declares that relationship and chooses Restrict deletion so the classroom does not conceal cascading behavior in conventions. Saving a tracked graph can populate generated keys and foreign-key values, but it is still the relational schema that rejects an orphan inserted by another path. Early application checks are useful feedback, not substitutes for that final invariant. Treat entity configuration, the created schema and the actual stored rows as three things that must agree.

Verify the graph and then attempt an actual orphan

The external connection explicitly enables SQLite foreign keys and stays open for the whole fixture. EF saves Blog A with one Post, and an Include query verifies both the generated BlogId and materialized principal name. A separate context then attempts BlogId 9999 without a corresponding principal. The exception filter requires DbUpdateException wrapping SQLite’s foreign-key constraint classification, rather than declaring every save error an expected orphan rejection. A fresh context confirms that the original post remains the only row. This is a genuine database constraint check with SQL execution, not a test double implementing expected list behavior.

using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<LabDb>().UseSqlite(connection).Options;
await using (var db = new LabDb(options))
{
    await db.Database.EnsureCreatedAsync(); db.Blogs.Add(new Blog { Name = "A", Posts = { new Post { Title = "First" } } }); await db.SaveChangesAsync();
    var row = await db.Posts.Include(p => p.Blog).SingleAsync(); Check(row.BlogId > 0 && row.Blog.Name == "A", "saved FK graph"); Console.WriteLine("related:" + row.Blog.Name);
}
bool rejected = false;
await using (var invalid = new LabDb(options))
{
    invalid.Posts.Add(new Post { Title = "Orphan", BlogId = 9999 });
    try { await invalid.SaveChangesAsync(); }
    catch (DbUpdateException ex) when (ex.InnerException is SqliteException { SqliteErrorCode: 19, SqliteExtendedErrorCode: 787 }) { rejected = true; }
}
await using (var verify = new LabDb(options)) Check(rejected && await verify.Posts.CountAsync() == 1, "foreign key rejected without orphan");
Console.WriteLine("foreign-key:rejected");

static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }

A supplied-reference check remains only early validation

The BCL practice receives parent IDs one and two and checks child references one and nine. Its known parent and unknown parent outputs follow HashSet membership under those supplied facts. The set does not observe a database or prevent another operation from deleting a parent after validation. It also does not implement EF relationship fixup, cascade rules or generated keys. Use the check to describe a caller’s input contract, then verify the persisted invariant with the real relational provider. Two checks producing the same rejection label can still protect different boundaries and have different race behavior.

Deleting principals and loading navigations are separate policies

Restrict, cascading deletion and optional foreign keys represent different data-retention decisions. Do not infer deletion behavior merely from a nullable C# navigation or from one object graph that happens to remain in memory. A navigation also is not proof that related data was queried; tracking fixup can populate some relationships from already tracked entities. This lesson uses fresh scopes and an explicit Include for its verification. Database providers can differ in constraint timing, supported cascade paths and error details. Preserve SQLite-specific exception handling locally rather than reusing its numeric code as a provider-independent domain API.

Practice: inspect the chosen delete rule on real storage

Using a new disposable fixture, attempt to delete the principal while its required dependent still exists and record the failure under the declared Restrict policy. Then create a separately configured cascade variation and verify both stored counts from a fresh context after deletion. Do not alter an existing database’s model and assume its schema changed automatically. Recreate the transient fixture or use an actual migration for the new schema. Keep orphan insertion, principal deletion and navigation loading as independent cases so one relationship behavior cannot stand in for another.

  • Hint: changing fluent configuration does not retroactively modify an existing schema.
  • Hint: verify both principal and dependent rows after the operation.

Practice: validate a complete batch before accepting references

Extend the supplied-reference exercise with duplicate principal IDs, several unknown references and a repeated dependent. Decide whether to reject the whole batch or return structured row-specific errors without mutating accepted state. That decision can be checked with ordinary collections, but it must not be advertised as a relational commit. Add a case where a parent disappears after the initial input snapshot and explain what the database constraint still contributes. Keep identity normalization explicit if identifiers become strings, since application equality and a database’s collation do not automatically share the same semantics.

  • Hint: validation policy and storage enforcement protect different moments.
  • Hint: name unknown-reference and duplicate-identity failures separately.

Keep configuration, enforcement and observation together

The complete external check establishes a saved relationship and a rejected orphan using native SQLite with foreign keys enabled. The portable result establishes only supplied membership. Neither proves another provider’s constraint behavior or a production migration’s correctness. A useful review names principal and foreign keys, nullability, delete behavior, schema creation strategy and the fresh query used to observe the result. Those facts remain valuable when a larger application uses several contexts or writers, because navigation convenience alone cannot supply the durable referential invariant that the storage layer is meant to enforce.

External net10.0 console; SDK10.0.403; Microsoft.EntityFrameworkCore.Sqlite 10.0.14; native SQLite relational provider. Portable practice is a separate .NET9 BCL-only program.

.NET10 / EF Core10 / real SQLite — Relationships and enforced foreign keys

Set up the SDK project

  1. Use an external .NET10 console environment. This downloadable project targets net10.0 and pins Microsoft.EntityFrameworkCore.Sqlite 10.0.14; it is not the SharpRune .NET9 browser practice.
  2. Restore this project with: dotnet restore EfLab.csproj --source https://api.nuget.org/v3/index.json. Use an installed compatible .NET10 SDK; validation used the isolated official SDK10.0.403.
  3. Run: dotnet run --project EfLab.csproj --no-restore. The source opens a real SQLite :memory: connection and keeps that connection alive across its sequential context scopes. No EF InMemory provider or fake ORM is used.
  4. Read the embedded Check assertions and the expected output below. EnsureCreated is used only for disposable classroom schema unless this is the dedicated migration lesson. A passing SQLite run is not evidence for another database provider.

Download complete SDK project ↗

Project files

EfLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.14" />
  </ItemGroup>
</Project>
Program.csDownload file ↗
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<LabDb>().UseSqlite(connection).Options;
await using (var db = new LabDb(options))
{
    await db.Database.EnsureCreatedAsync(); db.Blogs.Add(new Blog { Name = "A", Posts = { new Post { Title = "First" } } }); await db.SaveChangesAsync();
    var row = await db.Posts.Include(p => p.Blog).SingleAsync(); Check(row.BlogId > 0 && row.Blog.Name == "A", "saved FK graph"); Console.WriteLine("related:" + row.Blog.Name);
}
bool rejected = false;
await using (var invalid = new LabDb(options))
{
    invalid.Posts.Add(new Post { Title = "Orphan", BlogId = 9999 });
    try { await invalid.SaveChangesAsync(); }
    catch (DbUpdateException ex) when (ex.InnerException is SqliteException { SqliteErrorCode: 19, SqliteExtendedErrorCode: 787 }) { rejected = true; }
}
await using (var verify = new LabDb(options)) Check(rejected && await verify.Posts.CountAsync() == 1, "foreign key rejected without orphan");
Console.WriteLine("foreign-key:rejected");

static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }
Model.csDownload file ↗
using Microsoft.EntityFrameworkCore;
public sealed class LabDb : DbContext
{
    public LabDb(DbContextOptions<LabDb> options) : base(options) { }
    public DbSet<Blog> Blogs => Set<Blog>(); public DbSet<Post> Posts => Set<Post>();
    protected override void OnModelCreating(ModelBuilder model)
    {
        model.Entity<Post>().HasOne(p => p.Blog).WithMany(b => b.Posts).HasForeignKey(p => p.BlogId).OnDelete(DeleteBehavior.Restrict);
    }
}
public sealed class Blog { public int Id { get; set; } public string Name { get; set; } = ""; public List<Post> Posts { get; set; } = new(); }
public sealed class Post { public int Id { get; set; } public string Title { get; set; } = ""; public int BlogId { get; set; } public Blog Blog { get; set; } = null!; }

Expected result

The external program must complete its Check assertions and print: related:A foreign-key:rejected It uses actual SQLite materialization and saving; no external service or production database is contacted.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;

var parents = new HashSet<int> { 1, 2 };
foreach (var parentId in new[] { 1, 9 }) Console.WriteLine(parents.Contains(parentId) ? "known parent" : "unknown parent");
Open C# practice

C# practice output

known parent
unknown parent
Try this
An application lookup and a database foreign-key constraint provide different guarantees.

Further reading