Unit of Work Pattern
Collect new, changed and removed objects during a business operation, then commit them together to avoid partial writes and repeated database calls.
· Last updated: 2026-10-26 · 23 min read
Overview
Suppose confirming an order inserts an order row, inserts its items, reduces product stock and deletes a cart item. Writing immediately after each change makes four database calls. If the third write, the stock update, fails, the first two rows already exist: the order remains but stock is unchanged. The first output in the extended example shows this state.
A Unit of Work collects changes made during one business operation instead of writing them immediately, then applies them to storage together at the end. One operation means something meaningful to the user as a whole, such as confirming an order; Fowler calls it a business transaction.
It collects three kinds of changes. New objects become INSERTs; objects changed after being read, dirty objects, become UPDATEs; objects marked for removal become DELETEs. Adding a change to these lists is registration, and sending the collected changes to storage together is committing. In the examples, the order and order items are new, stock is dirty, and the cart item is removed.
Applying either all changes or none requires a storage transaction. A transaction groups database operations into one consistency boundary, committing on success and rolling back on failure. The unit of work collects changes for one transaction; the database supplies atomicity. Immediate writes can also be atomic when enclosed in one database transaction, but that transaction must stay open throughout the business operation. This article collects changes first and opens the transaction when committing. Operations needing consistency from the initial read must choose a transaction start point accordingly.
Structure
UnitOfWork keeps Row objects in added, dirty and removed lists. Plan() converts them into Changes ordered as INSERT, UPDATE and DELETE. Commit() submits that list once to Store.Apply() and returns a CommitResult.
Registration does not call storage. The only storage call is Commit() invoking Apply() once.
History
Martin Fowler described Unit of Work in Patterns of Enterprise Application Architecture (2002), with Dave Rice, Matthew Foemmel, Edward Hieatt, Robert Mee and Randy Stafford contributing to the book.1 Its online catalog classifies Unit of Work under Object-Relational Behavioral Patterns. It describes maintaining a list of objects affected by a business transaction and coordinating writing out changes and resolving concurrency problems.2
The same entry describes how changing the database whenever the object model changes produces many small, slow database calls, and how a business transaction spanning requests would require keeping a database transaction open. Its interface diagram lists registerNew, registerDirty, registerClean, registerDeleted, commit and rollback.3 These examples use RegisterRemoved instead of registerDeleted and implement neither registerClean nor rollback.
An ORM often fills this role. The Hibernate user guide describes Session as a short-lived, single-threaded object conceptually modeling PoEAA's Unit of Work. It identifies EntityManager as the Jakarta Persistence equivalent.4 EF Core likewise describes DbContext as representing a short-lived unit of work. It tracks entities in states such as Added, Modified and Deleted, executing INSERT, UPDATE and DELETE respectively when SaveChanges is called.5
Why use it?
It reduces storage calls. EF Core collects changes and sends batches on SaveChanges rather than writing each change immediately. The number of statements per batch depends on the provider. SQL Server defaults to at most 42 statements, with the rest executed in additional roundtrips.6
A failure need not leave partially applied changes. If the provider supports transactions, EF Core applies one SaveChanges call in a single transaction. Any failure rolls the transaction back, leaving the database unchanged.7
Execution order is separated from the caller's operation order. Hibernate says its ActionQueue determines SQL order rather than the order of entity-state operations. Its list places OrphanRemovalAction first, entity inserts before entity updates, and EntityDeleteAction last. As its example shows, removing an entity and adding another with the same key can therefore produce a key collision because INSERT runs before DELETE.8 Ordering differs among ORMs. The EF Core change-tracking example runs UPDATE, DELETE and INSERT in that order.9 This example's unit of work fixes table order so foreign-key parent rows, products and orders, are inserted before child rows, order items and cart items.
Concurrency conflicts can be checked at commit time. Optimistic concurrency avoids locking data in advance and fails a save if another party changed it after the read. EF Core includes properties configured as concurrency tokens in UPDATE and DELETE WHERE clauses. Zero affected rows causes DbUpdateConcurrencyException.10 Each row's Version supplies that token here.
Unit-of-work atomicity extends only to one database transaction. Sending an event to a message broker or calling a remote API is outside that commit, so a database commit can succeed while message publication fails. Transactional Outbox addresses this by saving outgoing messages in an outbox within the same transaction as business data, then using a separate relay to send them to the broker. The relay may send the same message more than once, so receivers must tolerate duplicates.11
The unit of work does not resolve conflicts itself. After a version conflict rejects a commit, calling code decides whether to reload and recalculate changes or inform the user. EF Core lists notifying the user, requerying and retrying, and merging values as options.12 A retry must rebuild changes from fresh values rather than resend the same change list.
Long-lived units of work accumulate tracking overhead and stale values that increase conflicts. EF Core describes a short default lifetime: create DbContext, track and change entities, call SaveChanges, then dispose it.13 For bulk updates applying the same change to many rows, EF Core documents ExecuteUpdate, which bypasses change tracking, as more efficient.14
Repository and Unit of Work have different responsibilities. A repository defines how business objects are retrieved and added to storage; a unit of work determines when changes to those objects are applied together.
Example
Register four changes needed to confirm an order, print the commit plan, then commit. Store is in memory and treats one Apply() call as one transaction. It applies changes to a copy and replaces the original only when all succeed.
using System;
using System.Collections.Generic;
using System.Linq;
var store = new Store();
store.Seed(new Row("products", "p1", "stock=5", 1));
store.Seed(new Row("cart_items", "c1", "p1 x2", 1));
var uow = new UnitOfWork(store);
// Register only; no storage write yet. Register the order item before its order.
uow.RegisterNew(new Row("order_lines", "l1", "o1 p1 x2", 0));
uow.RegisterNew(new Row("orders", "o1", "placed", 0));
uow.RegisterDirty(new Row("products", "p1", "stock=3", 1));
uow.RegisterRemoved(new Row("cart_items", "c1", "", 1));
Console.WriteLine("store calls before commit: " + store.Calls);
foreach (var change in uow.Plan()) Console.WriteLine("plan: " + change);
Console.WriteLine("commit: " + uow.Commit());
Console.WriteLine("store calls after commit: " + store.Calls);
foreach (var row in store.Rows()) Console.WriteLine("row: " + row);
enum Kind { Insert, Update, Delete }
sealed record Row(string Table, string Id, string Data, int Version)
{
public override string ToString() => $"{Table}/{Id} v{Version} {Data}";
}
sealed record Change(Kind Kind, Row Row)
{
public override string ToString() => $"{Kind.ToString().ToUpperInvariant()} {Row.Table}/{Row.Id}";
}
abstract record CommitResult;
sealed record Committed(int Changes) : CommitResult;
sealed record Rejected(string Code, string Key, string Detail) : CommitResult;
// In-memory storage: one Apply call acts as one transaction.
sealed class Store
{
private Dictionary<string, Row> rows = new();
public int Calls { get; private set; }
public void Seed(Row row) => rows[Key(row)] = row;
public IEnumerable<Row> Rows() => rows.Values.OrderBy(Key, StringComparer.Ordinal);
// Apply to a copy in order and replace the original only when all changes succeed.
public CommitResult Apply(IReadOnlyList<Change> changes)
{
Calls++;
var draft = new Dictionary<string, Row>(rows);
foreach (var change in changes)
{
var rejected = ApplyOne(draft, change);
if (rejected is not null) return rejected;
}
rows = draft;
return new Committed(changes.Count);
}
private static Rejected? ApplyOne(Dictionary<string, Row> draft, Change change)
{
var key = Key(change.Row);
var exists = draft.TryGetValue(key, out var current);
if (change.Kind == Kind.Insert)
{
if (exists) return new Rejected("DuplicateKey", key, "row already exists");
draft[key] = change.Row with { Version = 1 };
return null;
}
// Update or delete only if the read version still matches.
if (current is null) return new Rejected("VersionConflict", key, $"read v{change.Row.Version}, store missing");
if (current.Version != change.Row.Version)
return new Rejected("VersionConflict", key, $"read v{change.Row.Version}, store v{current.Version}");
if (change.Kind == Kind.Delete) draft.Remove(key);
else draft[key] = change.Row with { Version = current.Version + 1 };
return null;
}
private static string Key(Row row) => row.Table + "/" + row.Id;
}
// Unit of work: collect new, changed and removed rows, then submit together.
sealed class UnitOfWork(Store store)
{
// Put parent tables first. Insert in forward order and delete in reverse order.
private static readonly string[] TableOrder = ["products", "orders", "order_lines", "cart_items"];
private readonly List<Row> added = new();
private readonly List<Row> dirty = new();
private readonly List<Row> removed = new();
public void RegisterNew(Row row) => added.Add(row);
public void RegisterDirty(Row row) => dirty.Add(row);
public void RegisterRemoved(Row row) => removed.Add(row);
public IReadOnlyList<Change> Plan() =>
added.OrderBy(Rank).Select(r => new Change(Kind.Insert, r))
.Concat(dirty.OrderBy(Rank).Select(r => new Change(Kind.Update, r)))
.Concat(removed.OrderByDescending(Rank).Select(r => new Change(Kind.Delete, r)))
.ToList();
public CommitResult Commit()
{
// Reject an unordered table without calling storage.
var unknown = added.Concat(dirty).Concat(removed).FirstOrDefault(r => Rank(r) < 0);
if (unknown is not null)
return new Rejected("UnknownTable", unknown.Table + "/" + unknown.Id, "table has no commit order");
var result = store.Apply(Plan());
if (result is not Committed) return result;
added.Clear();
dirty.Clear();
removed.Clear();
return result;
}
private static int Rank(Row row) => Array.IndexOf(TableOrder, row.Table);
}store calls before commit: 0
plan: INSERT orders/o1
plan: INSERT order_lines/l1
plan: UPDATE products/p1
plan: DELETE cart_items/c1
commit: Committed { Changes = 4 }
store calls after commit: 1
row: order_lines/l1 v1 o1 p1 x2
row: orders/o1 v1 placed
row: products/p1 v2 stock=3Although the order item was registered first, the plan puts the order first. Storage is called only once, during commit.
Extended example
Create two stores where product p1 was read at version 1 but another request has since changed stock to version 2. Apply each change immediately to the first store; commit the same changes together through a unit of work to the second. Finally, register a row whose table has no configured commit order. Outcomes are Committed or Rejected values rather than exceptions.
using System;
using System.Collections.Generic;
using System.Linq;
var order = new Row("orders", "o1", "placed", 0);
var line = new Row("order_lines", "l1", "o1 p1 x2", 0);
var stock = new Row("products", "p1", "stock=3", 1);
var cart = new Row("cart_items", "c1", "", 1);
Console.WriteLine("== immediate writes");
var direct = NewStoreWithConcurrentChange();
var callsBefore = direct.Calls;
// Call storage for each change and stop at the first failure.
Change[] steps =
[
new(Kind.Insert, order),
new(Kind.Insert, line),
new(Kind.Update, stock),
new(Kind.Delete, cart),
];
foreach (var step in steps)
{
var result = direct.Apply([step]);
Console.WriteLine(step + ": " + result);
if (result is not Committed) break;
}
Console.WriteLine("store calls: " + (direct.Calls - callsBefore));
Dump("after", direct);
Console.WriteLine("== unit of work");
var store = NewStoreWithConcurrentChange();
callsBefore = store.Calls;
Dump("before", store);
var uow = new UnitOfWork(store);
uow.RegisterNew(order);
uow.RegisterNew(line);
uow.RegisterDirty(stock);
uow.RegisterRemoved(cart);
var commit = uow.Commit();
Console.WriteLine("commit: " + Describe(commit));
Console.WriteLine("store calls: " + (store.Calls - callsBefore));
Dump("after", store);
Console.WriteLine("== unknown table");
callsBefore = store.Calls;
var audit = new UnitOfWork(store);
audit.RegisterNew(new Row("audit_log", "a1", "order o1 placed", 0));
Console.WriteLine("commit: " + Describe(audit.Commit()));
Console.WriteLine("store calls: " + (store.Calls - callsBefore));
// Create a store where p1 was read at v1, then another request changed stock to v2.
static Store NewStoreWithConcurrentChange()
{
var s = new Store();
s.Seed(new Row("products", "p1", "stock=5", 1));
s.Seed(new Row("cart_items", "c1", "p1 x2", 1));
s.Apply([new Change(Kind.Update, new Row("products", "p1", "stock=4", 1))]);
return s;
}
// Return failures as results. The caller decides whether to reload and recalculate.
static string Describe(CommitResult result) => result switch
{
Committed ok => $"committed {ok.Changes} changes",
Rejected no => $"rejected {no.Code} at {no.Key} ({no.Detail})",
_ => "unknown result",
};
static void Dump(string label, Store s)
{
foreach (var row in s.Rows()) Console.WriteLine(label + ": " + row);
}
enum Kind { Insert, Update, Delete }
sealed record Row(string Table, string Id, string Data, int Version)
{
public override string ToString() => $"{Table}/{Id} v{Version} {Data}";
}
sealed record Change(Kind Kind, Row Row)
{
public override string ToString() => $"{Kind.ToString().ToUpperInvariant()} {Row.Table}/{Row.Id}";
}
abstract record CommitResult;
sealed record Committed(int Changes) : CommitResult;
sealed record Rejected(string Code, string Key, string Detail) : CommitResult;
// In-memory storage: one Apply call acts as one transaction.
sealed class Store
{
private Dictionary<string, Row> rows = new();
public int Calls { get; private set; }
public void Seed(Row row) => rows[Key(row)] = row;
public IEnumerable<Row> Rows() => rows.Values.OrderBy(Key, StringComparer.Ordinal);
// Apply to a copy in order and replace the original only when all changes succeed.
public CommitResult Apply(IReadOnlyList<Change> changes)
{
Calls++;
var draft = new Dictionary<string, Row>(rows);
foreach (var change in changes)
{
var rejected = ApplyOne(draft, change);
if (rejected is not null) return rejected;
}
rows = draft;
return new Committed(changes.Count);
}
private static Rejected? ApplyOne(Dictionary<string, Row> draft, Change change)
{
var key = Key(change.Row);
var exists = draft.TryGetValue(key, out var current);
if (change.Kind == Kind.Insert)
{
if (exists) return new Rejected("DuplicateKey", key, "row already exists");
draft[key] = change.Row with { Version = 1 };
return null;
}
// Update or delete only if the read version still matches.
if (current is null) return new Rejected("VersionConflict", key, $"read v{change.Row.Version}, store missing");
if (current.Version != change.Row.Version)
return new Rejected("VersionConflict", key, $"read v{change.Row.Version}, store v{current.Version}");
if (change.Kind == Kind.Delete) draft.Remove(key);
else draft[key] = change.Row with { Version = current.Version + 1 };
return null;
}
private static string Key(Row row) => row.Table + "/" + row.Id;
}
// Unit of work: collect new, changed and removed rows, then submit together.
sealed class UnitOfWork(Store store)
{
// Put parent tables first. Insert in forward order and delete in reverse order.
private static readonly string[] TableOrder = ["products", "orders", "order_lines", "cart_items"];
private readonly List<Row> added = new();
private readonly List<Row> dirty = new();
private readonly List<Row> removed = new();
public void RegisterNew(Row row) => added.Add(row);
public void RegisterDirty(Row row) => dirty.Add(row);
public void RegisterRemoved(Row row) => removed.Add(row);
public IReadOnlyList<Change> Plan() =>
added.OrderBy(Rank).Select(r => new Change(Kind.Insert, r))
.Concat(dirty.OrderBy(Rank).Select(r => new Change(Kind.Update, r)))
.Concat(removed.OrderByDescending(Rank).Select(r => new Change(Kind.Delete, r)))
.ToList();
public CommitResult Commit()
{
// Reject an unordered table without calling storage.
var unknown = added.Concat(dirty).Concat(removed).FirstOrDefault(r => Rank(r) < 0);
if (unknown is not null)
return new Rejected("UnknownTable", unknown.Table + "/" + unknown.Id, "table has no commit order");
var result = store.Apply(Plan());
if (result is not Committed) return result;
added.Clear();
dirty.Clear();
removed.Clear();
return result;
}
private static int Rank(Row row) => Array.IndexOf(TableOrder, row.Table);
}== immediate writes
INSERT orders/o1: Committed { Changes = 1 }
INSERT order_lines/l1: Committed { Changes = 1 }
UPDATE products/p1: Rejected { Code = VersionConflict, Key = products/p1, Detail = read v1, store v2 }
store calls: 3
after: cart_items/c1 v1 p1 x2
after: order_lines/l1 v1 o1 p1 x2
after: orders/o1 v1 placed
after: products/p1 v2 stock=4
== unit of work
before: cart_items/c1 v1 p1 x2
before: products/p1 v2 stock=4
commit: rejected VersionConflict at products/p1 (read v1, store v2)
store calls: 1
after: cart_items/c1 v1 p1 x2
after: products/p1 v2 stock=4
== unknown table
commit: rejected UnknownTable at audit_log/a1 (table has no commit order)
store calls: 0Immediate writes call storage three times and stop, leaving only the order and its item. The unit of work calls storage once and is rejected, with storage unchanged from before the commit. A row in an unordered table is rejected before storage is called.
The immediate-write case leaves partial data because it has no transaction. One database transaction around all writes would also prevent partial application, but it would remain open throughout the business operation. Copy replacement in the in-memory store only simulates atomicity here; a real database supplies it through transactions. After rejection, the unit of work still holds its registered changes; no reload-and-recalculate path is implemented. Duplicate registration of a row and removing a newly registered row are also unhandled. In EF Core, removing an Added entity detaches it without inserting it.15 Removing and reinserting the same key is rejected as DuplicateKey because the plan puts INSERT before DELETE. A fixed array defines table order rather than deriving it from foreign keys. A table absent from that array makes the commit return UnknownTable without calling storage.
TypeScript example
The same structure in TypeScript uses a union discriminated by ok for commit outcomes. Output shows the pre-commit state, plan, rejected outcome and post-commit state in order.
type Kind = "INSERT" | "UPDATE" | "DELETE";
type Row = { table: string; id: string; data: string; version: number };
type Change = { kind: Kind; row: Row };
type CommitResult =
| { ok: true; changes: number }
| { ok: false; code: "DuplicateKey" | "VersionConflict" | "UnknownTable"; key: string; detail: string };
const keyOf = (row: Row): string => `${row.table}/${row.id}`;
const show = (row: Row): string => `${keyOf(row)} v${row.version} ${row.data}`;
// In-memory storage: one apply call acts as one transaction.
class Store {
#rows = new Map<string, Row>();
calls = 0;
seed(row: Row): void { this.#rows.set(keyOf(row), row); }
rows(): Row[] { return [...this.#rows.values()].sort((a, b) => (keyOf(a) < keyOf(b) ? -1 : 1)); }
// Apply to a copy in order and replace the original only when all changes succeed.
apply(changes: Change[]): CommitResult {
this.calls++;
const draft = new Map(this.#rows);
for (const change of changes) {
const rejected = applyOne(draft, change);
if (rejected !== null) return rejected;
}
this.#rows = draft;
return { ok: true, changes: changes.length };
}
}
function applyOne(draft: Map<string, Row>, change: Change): CommitResult | null {
const key = keyOf(change.row);
const current = draft.get(key);
if (change.kind === "INSERT") {
if (current !== undefined) return { ok: false, code: "DuplicateKey", key, detail: "row already exists" };
draft.set(key, { ...change.row, version: 1 });
return null;
}
// Update or delete only if the read version still matches.
if (current === undefined)
return { ok: false, code: "VersionConflict", key, detail: `read v${change.row.version}, store missing` };
if (current.version !== change.row.version)
return { ok: false, code: "VersionConflict", key, detail: `read v${change.row.version}, store v${current.version}` };
if (change.kind === "DELETE") draft.delete(key);
else draft.set(key, { ...change.row, version: current.version + 1 });
return null;
}
// Unit of work: collect new, changed and removed rows, then submit together.
class UnitOfWork {
// Put parent tables first. Insert in forward order and delete in reverse order.
static readonly tableOrder = ["products", "orders", "order_lines", "cart_items"];
#added: Row[] = [];
#dirty: Row[] = [];
#removed: Row[] = [];
constructor(private readonly store: Store) {}
registerNew(row: Row): void { this.#added.push(row); }
registerDirty(row: Row): void { this.#dirty.push(row); }
registerRemoved(row: Row): void { this.#removed.push(row); }
plan(): Change[] {
const byRank = (a: Row, b: Row): number => UnitOfWork.rank(a) - UnitOfWork.rank(b);
return [
...[...this.#added].sort(byRank).map((row): Change => ({ kind: "INSERT", row })),
...[...this.#dirty].sort(byRank).map((row): Change => ({ kind: "UPDATE", row })),
...[...this.#removed].sort((a, b) => byRank(b, a)).map((row): Change => ({ kind: "DELETE", row })),
];
}
commit(): CommitResult {
// Reject an unordered table without calling storage.
const unknown = [...this.#added, ...this.#dirty, ...this.#removed].find((row) => UnitOfWork.rank(row) < 0);
if (unknown !== undefined)
return { ok: false, code: "UnknownTable", key: keyOf(unknown), detail: "table has no commit order" };
const result = this.store.apply(this.plan());
if (!result.ok) return result;
this.#added = [];
this.#dirty = [];
this.#removed = [];
return result;
}
static rank(row: Row): number { return UnitOfWork.tableOrder.indexOf(row.table); }
}
// Create a store where p1 was read at v1, then another request changed stock to v2.
const store = new Store();
store.seed({ table: "products", id: "p1", data: "stock=5", version: 1 });
store.seed({ table: "cart_items", id: "c1", data: "p1 x2", version: 1 });
store.apply([{ kind: "UPDATE", row: { table: "products", id: "p1", data: "stock=4", version: 1 } }]);
const callsBefore = store.calls;
for (const row of store.rows()) console.log("before: " + show(row));
const uow = new UnitOfWork(store);
uow.registerNew({ table: "order_lines", id: "l1", data: "o1 p1 x2", version: 0 });
uow.registerNew({ table: "orders", id: "o1", data: "placed", version: 0 });
uow.registerDirty({ table: "products", id: "p1", data: "stock=3", version: 1 });
uow.registerRemoved({ table: "cart_items", id: "c1", data: "", version: 1 });
for (const change of uow.plan()) console.log(`plan: ${change.kind} ${keyOf(change.row)}`);
const result = uow.commit();
// Return failures as results. The caller decides whether to reload and recalculate.
console.log(result.ok
? `commit: committed ${result.changes} changes`
: `commit: rejected ${result.code} at ${result.key} (${result.detail})`);
console.log("store calls: " + (store.calls - callsBefore));
for (const row of store.rows()) console.log("after: " + show(row));before: cart_items/c1 v1 p1 x2
before: products/p1 v2 stock=4
plan: INSERT orders/o1
plan: INSERT order_lines/l1
plan: UPDATE products/p1
plan: DELETE cart_items/c1
commit: rejected VersionConflict at products/p1 (read v1, store v2)
store calls: 1
after: cart_items/c1 v1 p1 x2
after: products/p1 v2 stock=4The plan contained four changes, but storage detected the version conflict and applied none. This example also uses in-memory storage and implements no database transaction, reload or retry.
See also
Each C# example is a standalone .NET 10 console program.
Compile the TypeScript examples to JavaScript targeting ES2022 or later, then run them in Node.js.
Footnotes
- Martin Fowler, with Dave Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford. Patterns of Enterprise Application Architecture (2002). Author's book page. ↩
- Martin Fowler. Unit of Work. PoEAA online catalog entry. Classification: Object-Relational Behavioral Patterns in the catalog contents. ↩
- Martin Fowler. Unit of Work. Second paragraph and interface diagram. ↩
- Hibernate ORM 6.6 User Guide. Chapter 2, Architecture: 2.1 Overview. Session entry. ↩
- Microsoft Learn. Change Tracking - EF Core. How to track entities; Entity states. ↩
- Microsoft Learn. Efficient Updating - EF Core. Batching. ↩
- Microsoft Learn. Transactions - EF Core. Default transaction behavior. ↩
- Hibernate ORM 6.6 User Guide. 7.5 Flush operation order. ActionQueue list and Example 448. ↩
- Microsoft Learn. Change Tracking - EF Core. SQL log under Query then insert, update, and delete. ↩
- Microsoft Learn. Handling Concurrency Conflicts - EF Core. Optimistic concurrency; Resolving concurrency conflicts. ↩
- Chris Richardson. Pattern: Transactional outbox. microservices.io. ↩
- Microsoft Learn. Handling Concurrency Conflicts - EF Core. Resolving concurrency conflicts. ↩
- Microsoft Learn. Change Tracking - EF Core. DbContext lifetime steps under How to track entities. ↩
- Microsoft Learn. Efficient Updating - EF Core. Use ExecuteUpdate and ExecuteDelete when relevant. ↩
- Microsoft Learn. Basic SaveChanges - EF Core. Deleting Data. ↩