Repository Pattern
Put aggregate storage behind a collection-like interface so business code remains independent of persistence technology.
· Last updated: 2026-10-26 · 22 min read
Overview
Suppose we are implementing order cancellation. A CancelOrder method reads a row with SELECT ... FROM orders WHERE id = @id, copies its columns into an order object, checks whether it is already cancelled, and executes UPDATE orders SET status = .... Testing one cancellation rule now requires a database, while the SQL for finding a customer's orders gets copied into cancellation, refund and notification code. Changing storage means opening every method containing business rules.
A Repository moves storage and retrieval behind an interface resembling an object collection. Business code adds and retrieves orders through methods such as Add, GetById, FindByCustomer, without knowing whether SQL, files or an in-memory dictionary sit behind them. The interface belongs on the business-code side and its implementation in an outer layer that knows persistence technology. The storage implementation thus depends on the business interface rather than business code depending on storage technology.
Repositories usually operate per aggregate. An aggregate is a group of objects, such as an order and its items, whose consistency must be maintained together. Its root is the representative object that outside objects may reference.1 Here, Order is the root and enforces cancellation rules itself. Rather than one repository per order-item table, a single IOrderRepository reads and writes complete orders.
Structure
OrderService holds a reference only to IOrderRepository. InMemoryOrderRepository implements that interface and keeps Order objects in a dictionary. Supplying another implementation leaves the service code unchanged.
History
Repository appears in Patterns of Enterprise Application Architecture (2002). Fowler's book page lists Martin Fowler with Dave Rice, Matthew Foemmel, Edward Hieatt, Robert Mee and Randy Stafford as authors.2 The pattern catalog on the same site credits the Repository entry to Edward Hieatt and Rob Mee. It summarizes a mediator between the domain and data-mapping layers, the latter transferring values between objects and database tables, through a collection-like interface for domain objects. Clients construct query specifications declaratively and submit them to the repository. Making the dependency between domain and mapping layers flow in one direction is another stated purpose.3
Eric Evans connected repositories with aggregates in Domain-Driven Design, published in August 2003 with a 2004 copyright date.4 In DDD Reference, Evans's refined summary of the book's patterns, Repositories calls for a service for each aggregate type needing global access, resembling an in-memory collection of all objects of its root type. It supplies methods for adding, removing and selecting objects by criteria meaningful to domain experts. Repositories should exist only for aggregate roots that actually need direct access.5
Microsoft's .NET microservices architecture guide recommends one repository per aggregate root rather than per database table. It says EF Core's DbContext implements both Repository and Unit of Work,6 DbContext is also described as a combination of the two in its API reference.7
Why use it?
A repository separates business rules from persistence technology. Since a service knows only a collection-like storage contract, tests of cancellation rules can use an in-memory implementation while production uses a database implementation. Data-access code gathers in one place instead of copying query SQL into each service. If testing without the production database is chosen, EF Core's documentation recommends a repository layer that tests can replace with a stub returning preset values or a mock recording calls.8
That document also explains the cost. Wrapping every LINQ query to be tested in repository methods adds a layer with substantial implementation and maintenance work. The wrapped queries still require tests against the actual database. Passing tests with a double does not establish production-database behavior such as case-sensitive comparisons.9
With EF Core, DbContext already serves as a repository and unit of work. Microsoft's sample repositories delegate change tracking, remembering changes to loaded objects for later persistence, to DbContext rather than recreating it. The guide does not treat repositories as mandatory in DDD. It includes feedback from Jimmy Bogard, who prefers not to use them because they hide important details of the persistence mechanism.10
A generic repository (IRepository<T>) giving every entity the same methods makes creating one per entity easy, encouraging repositories per table rather than per aggregate. Returning IQueryable<T> lets callers append arbitrary LINQ expressions, whose SQL translatability depends on the ORM. EF Core's documentation explains that query translation still involves EF Core, defeating the purpose of the repository boundary.11 Mark Seemann criticizes APIs returning IQueryable<T> more broadly. He argues that interfaces such as IQueryable<T> Query<T>() falsely promise to support any query and create an illusion of interchangeable implementations.12 Microsoft's IRepository<T> where T : IAggregateRoot serves a different purpose: the constraint marks repositories as belonging only to aggregate roots. Actual methods are defined per aggregate, as in IOrderRepository.13
Repositories support loading, changing and saving aggregates. Forcing read-only queries, such as an admin list joining several tables into a screen projection, through aggregates loads unused rules and fields. Microsoft's guide likewise allows queries to take another route while repositories and aggregate roots handle updates.14 These read-only queries are covered in Query Facade. Saving several changes in one transaction is also outside the repository's responsibility; that belongs to Unit of Work Pattern.
Example
Add an order, cancel it through a service, and retrieve orders by customer. The service receives only an IOrderRepository.
using System;
using System.Collections.Generic;
using System.Linq;
var orders = new InMemoryOrderRepository();
orders.Add(new Order("o-1", "c-1", 120));
orders.Add(new Order("o-2", "c-1", 80));
orders.Add(new Order("o-3", "c-2", 50));
var service = new OrderService(orders);
Console.WriteLine("cancel o-1: " + service.Cancel("o-1"));
Console.WriteLine("cancel o-9: " + service.Cancel("o-9"));
foreach (var order in orders.FindByCustomer("c-1"))
Console.WriteLine($"{order.Id} {order.Status} {order.Total}");
enum OrderStatus { Open, Cancelled }
// Aggregate root: the order enforces its cancellation rules.
sealed class Order(string id, string customerId, int total)
{
public string Id { get; } = id;
public string CustomerId { get; } = customerId;
public int Total { get; } = total;
public OrderStatus Status { get; private set; } = OrderStatus.Open;
public bool Cancel()
{
if (Status == OrderStatus.Cancelled) return false;
Status = OrderStatus.Cancelled;
return true;
}
}
// The service knows only this collection-like contract.
interface IOrderRepository
{
void Add(Order order);
Order? GetById(string id);
IReadOnlyList<Order> FindByCustomer(string customerId);
}
// The persistence-aware side; here storage is one dictionary.
sealed class InMemoryOrderRepository : IOrderRepository
{
private readonly Dictionary<string, Order> orders = new();
// Dictionary.Add throws for a duplicate ID. The next example returns Duplicate instead.
public void Add(Order order) => orders.Add(order.Id, order);
public Order? GetById(string id) => orders.GetValueOrDefault(id);
public IReadOnlyList<Order> FindByCustomer(string customerId) =>
orders.Values.Where(o => o.CustomerId == customerId).OrderBy(o => o.Id, StringComparer.Ordinal).ToList();
}
// A service unaware of where or how orders are stored.
sealed class OrderService(IOrderRepository orders)
{
public string Cancel(string orderId)
{
var order = orders.GetById(orderId);
if (order is null) return "not found";
return order.Cancel() ? "cancelled" : "already cancelled";
}
}cancel o-1: cancelled
cancel o-9: not found
o-1 Cancelled 120
o-2 Open 80The in-memory GetById returns the stored object itself, so changes made by Cancel() are immediately visible without a save call. A database implementation needs a separate decision about when to persist changes; the next example makes that explicit through Save.
Extended example
This time storage keeps copies of values instead of object references, constructing a new Order for every read. This resembles two requests reading the same database row independently. Missing orders and duplicate additions return a result type instead of throwing. Then invokes the next step only if the preceding step succeeds.
Two implementations share one interface. Version increases by one on every save, identifying whether another request saved since an order was read. Successful additions and saves update the object's Version as well, so the same object can be saved again, as with o-2. LastWriteWinsOrderRepository accepts the latest save without comparing versions. VersionCheckedOrderRepository returns VersionConflict if the read version differs from the stored version. Request B reads o-1 first. Request A then cancels and saves it; B adds an item to its pre-cancellation copy and saves.
using System;
using System.Collections.Generic;
using System.Linq;
IOrderRepository[] stores = [new LastWriteWinsOrderRepository(), new VersionCheckedOrderRepository()];
foreach (var repo in stores)
{
Console.WriteLine("== " + repo.GetType().Name);
var service = new OrderService(repo);
Console.WriteLine("add o-1: " + repo.Add(new Order("o-1", "c-1", 120)));
// After a successful add or save, update this object's Version so it can be saved again.
var o2 = new Order("o-2", "c-1", 80);
Console.WriteLine("add o-2: " + repo.Add(o2));
Console.WriteLine("o-2 add item: " + o2.AddItem(5).Then(repo.Save));
Console.WriteLine("o-2 add item again: " + o2.AddItem(5).Then(repo.Save));
Console.WriteLine("add o-1 again: " + repo.Add(new Order("o-1", "c-9", 10)));
Console.WriteLine("cancel o-9: " + service.Cancel("o-9"));
// Request B reads o-1 first.
var requestB = repo.GetById("o-1");
// Request A reads the same order, cancels it and saves.
Console.WriteLine("A cancel o-1: " + service.Cancel("o-1"));
// Request B adds an item to its pre-cancellation copy and saves.
Console.WriteLine("B add item: " + requestB.Then(o => o.AddItem(30)).Then(repo.Save));
Console.WriteLine("o-1 now: " + repo.GetById("o-1"));
Console.WriteLine("c-1 open orders: " + service.OpenCount("c-1"));
}
enum OrderStatus { Open, Cancelled }
enum ErrorCode { NotFound, Duplicate, AlreadyCancelled, VersionConflict }
sealed record Error(ErrorCode Code, string Detail);
// Return failures as values. A private constructor restricts construction to Ok and Fail;
// default(Result<T>) is null and cannot be read as a successful result.
sealed class Result<T>
{
private readonly T? value;
private readonly Error? error;
private Result(T? value, Error? error) { this.value = value; this.error = error; }
public static Result<T> Ok(T value) => new(value, null);
public static Result<T> Fail(ErrorCode code, string detail) => new(default, new Error(code, detail));
public Result<U> Then<U>(Func<T, Result<U>> next) =>
error is null ? next(value!) : new Result<U>(default, error);
public override string ToString() => error is null ? $"ok {value}" : $"{error.Code} ({error.Detail})";
}
// Aggregate root. Version records the stored version at the last read or save.
sealed class Order(string id, string customerId, int total,
OrderStatus status = OrderStatus.Open, int version = 0)
{
public string Id { get; } = id;
public string CustomerId { get; } = customerId;
public int Total { get; private set; } = total;
public OrderStatus Status { get; private set; } = status;
public int Version { get; private set; } = version;
// Call only after the repository successfully adds or saves.
public void MarkStored(int version) => Version = version;
public Result<Order> Cancel()
{
if (Status == OrderStatus.Cancelled) return Result<Order>.Fail(ErrorCode.AlreadyCancelled, Id);
Status = OrderStatus.Cancelled;
return Result<Order>.Ok(this);
}
public Result<Order> AddItem(int price)
{
if (Status == OrderStatus.Cancelled) return Result<Order>.Fail(ErrorCode.AlreadyCancelled, Id);
Total += price;
return Result<Order>.Ok(this);
}
public override string ToString() => $"{Id} {Status} total={Total} v{Version}";
}
interface IOrderRepository
{
Result<int> Add(Order order);
Result<Order> GetById(string id);
IReadOnlyList<Order> FindByCustomer(string customerId);
Result<int> Save(Order order);
}
// Store value copies rather than objects. Every read constructs a new Order.
sealed record StoredOrder(string Id, string CustomerId, int Total, OrderStatus Status, int Version)
{
public static StoredOrder From(Order o, int version) => new(o.Id, o.CustomerId, o.Total, o.Status, version);
public Order ToOrder() => new(Id, CustomerId, Total, Status, Version);
}
// First implementation: last write wins, without comparing versions.
sealed class LastWriteWinsOrderRepository : IOrderRepository
{
private readonly Dictionary<string, StoredOrder> rows = new();
public Result<int> Add(Order order)
{
if (!rows.TryAdd(order.Id, StoredOrder.From(order, 1))) return Result<int>.Fail(ErrorCode.Duplicate, order.Id);
order.MarkStored(1);
return Result<int>.Ok(1);
}
public Result<Order> GetById(string id) =>
rows.TryGetValue(id, out var row) ? Result<Order>.Ok(row.ToOrder()) : Result<Order>.Fail(ErrorCode.NotFound, id);
public IReadOnlyList<Order> FindByCustomer(string customerId) =>
rows.Values.Where(r => r.CustomerId == customerId).OrderBy(r => r.Id, StringComparer.Ordinal).Select(r => r.ToOrder()).ToList();
public Result<int> Save(Order order)
{
if (!rows.TryGetValue(order.Id, out var current)) return Result<int>.Fail(ErrorCode.NotFound, order.Id);
var next = current.Version + 1;
rows[order.Id] = StoredOrder.From(order, next);
order.MarkStored(next);
return Result<int>.Ok(next);
}
}
// Second implementation: reject a save if the read version differs from the current one.
sealed class VersionCheckedOrderRepository : IOrderRepository
{
private readonly Dictionary<string, StoredOrder> rows = new();
public Result<int> Add(Order order)
{
if (!rows.TryAdd(order.Id, StoredOrder.From(order, 1))) return Result<int>.Fail(ErrorCode.Duplicate, order.Id);
order.MarkStored(1);
return Result<int>.Ok(1);
}
public Result<Order> GetById(string id) =>
rows.TryGetValue(id, out var row) ? Result<Order>.Ok(row.ToOrder()) : Result<Order>.Fail(ErrorCode.NotFound, id);
public IReadOnlyList<Order> FindByCustomer(string customerId) =>
rows.Values.Where(r => r.CustomerId == customerId).OrderBy(r => r.Id, StringComparer.Ordinal).Select(r => r.ToOrder()).ToList();
public Result<int> Save(Order order)
{
if (!rows.TryGetValue(order.Id, out var current)) return Result<int>.Fail(ErrorCode.NotFound, order.Id);
if (current.Version != order.Version)
return Result<int>.Fail(ErrorCode.VersionConflict, $"{order.Id} read v{order.Version}, stored v{current.Version}");
var next = current.Version + 1;
rows[order.Id] = StoredOrder.From(order, next);
order.MarkStored(next);
return Result<int>.Ok(next);
}
}
// The same code works with either repository implementation.
sealed class OrderService(IOrderRepository orders)
{
public Result<int> Cancel(string orderId) =>
orders.GetById(orderId).Then(o => o.Cancel()).Then(orders.Save);
public int OpenCount(string customerId) =>
orders.FindByCustomer(customerId).Count(o => o.Status == OrderStatus.Open);
}== LastWriteWinsOrderRepository
add o-1: ok 1
add o-2: ok 1
o-2 add item: ok 2
o-2 add item again: ok 3
add o-1 again: Duplicate (o-1)
cancel o-9: NotFound (o-9)
A cancel o-1: ok 2
B add item: ok 3
o-1 now: ok o-1 Open total=150 v3
c-1 open orders: 2
== VersionCheckedOrderRepository
add o-1: ok 1
add o-2: ok 1
o-2 add item: ok 2
o-2 add item again: ok 3
add o-1 again: Duplicate (o-1)
cancel o-9: NotFound (o-9)
A cancel o-1: ok 2
B add item: VersionConflict (o-1 read v1, stored v2)
o-1 now: ok o-1 Cancelled total=120 v2
c-1 open orders: 1In the first implementation, B overwrites A's cancellation and o-1 becomes Open again. With the same service code, the second reports VersionConflict and lets the caller decide whether to reread and retry or inform the user. EF Core performs this check with a concurrency token. A WHERE condition on the original version makes an UPDATE changing zero rows cause DbUpdateConcurrencyException.15 SaveChanges can be called from the repository's Save or from a unit of work. Catching the exception there and translating it into VersionConflict preserves the service shape shown here. Fowler's catalog covers detecting and rolling back conflicts between concurrent business transactions in David Rice's Optimistic Offline Lock entry.16
A conflict leaves the rejected object's Version unchanged, so retrying requires rereading the order. This example's comparison and write are not atomic and are correct only on one thread. Concurrent Save calls can both pass the check. A database should combine comparison and update in one conditional UPDATE statement. Transactions, saves across several aggregates and query-specification objects are not implemented here.
TypeScript example
The same scenario in TypeScript uses a Result union discriminated by ok. Its andThen serves the same purpose as C#'s Then.
type OrderStatus = "Open" | "Cancelled";
type ErrorCode = "NotFound" | "Duplicate" | "AlreadyCancelled" | "VersionConflict";
type AppError = { code: ErrorCode; detail: string };
// Return failures as values rather than exceptions.
type Result<T> = { ok: true; value: T } | { ok: false; error: AppError };
const ok = <T>(value: T): Result<T> => ({ ok: true, value });
const fail = <T>(code: ErrorCode, detail: string): Result<T> => ({ ok: false, error: { code, detail } });
const andThen = <A, B>(r: Result<A>, next: (a: A) => Result<B>): Result<B> => (r.ok ? next(r.value) : r);
const show = <T>(r: Result<T>): string => (r.ok ? `ok ${String(r.value)}` : `${r.error.code} (${r.error.detail})`);
// Aggregate root. version records the stored version at the last read or save.
class Order {
#total: number;
#status: OrderStatus;
#version: number;
constructor(readonly id: string, readonly customerId: string, total: number,
status: OrderStatus = "Open", version = 0) {
this.#total = total;
this.#status = status;
this.#version = version;
}
get version(): number { return this.#version; }
// Call only after the repository successfully adds or saves.
markStored(version: number): void { this.#version = version; }
get total(): number { return this.#total; }
get status(): OrderStatus { return this.#status; }
cancel(): Result<Order> {
if (this.#status === "Cancelled") return fail("AlreadyCancelled", this.id);
this.#status = "Cancelled";
return ok(this);
}
addItem(price: number): Result<Order> {
if (this.#status === "Cancelled") return fail("AlreadyCancelled", this.id);
this.#total += price;
return ok(this);
}
toString(): string { return `${this.id} ${this.#status} total=${this.#total} v${this.#version}`; }
}
interface OrderRepository {
add(order: Order): Result<number>;
getById(id: string): Result<Order>;
findByCustomer(customerId: string): Order[];
save(order: Order): Result<number>;
}
// Store value copies rather than objects. Every read constructs a new Order.
type StoredOrder = { id: string; customerId: string; total: number; status: OrderStatus; version: number };
const toRow = (o: Order, version: number): StoredOrder =>
({ id: o.id, customerId: o.customerId, total: o.total, status: o.status, version });
const toOrder = (r: StoredOrder): Order => new Order(r.id, r.customerId, r.total, r.status, r.version);
// First implementation: last write wins, without comparing versions.
class LastWriteWinsOrderRepository implements OrderRepository {
readonly #rows = new Map<string, StoredOrder>();
add(order: Order): Result<number> {
if (this.#rows.has(order.id)) return fail("Duplicate", order.id);
this.#rows.set(order.id, toRow(order, 1));
order.markStored(1);
return ok(1);
}
getById(id: string): Result<Order> {
const row = this.#rows.get(id);
return row === undefined ? fail("NotFound", id) : ok(toOrder(row));
}
findByCustomer(customerId: string): Order[] {
return [...this.#rows.values()].filter((r) => r.customerId === customerId)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).map(toOrder);
}
save(order: Order): Result<number> {
const current = this.#rows.get(order.id);
if (current === undefined) return fail("NotFound", order.id);
const next = current.version + 1;
this.#rows.set(order.id, toRow(order, next));
order.markStored(next);
return ok(next);
}
}
// Second implementation: reject a save if the read version differs from the current one.
class VersionCheckedOrderRepository implements OrderRepository {
readonly #rows = new Map<string, StoredOrder>();
add(order: Order): Result<number> {
if (this.#rows.has(order.id)) return fail("Duplicate", order.id);
this.#rows.set(order.id, toRow(order, 1));
order.markStored(1);
return ok(1);
}
getById(id: string): Result<Order> {
const row = this.#rows.get(id);
return row === undefined ? fail("NotFound", id) : ok(toOrder(row));
}
findByCustomer(customerId: string): Order[] {
return [...this.#rows.values()].filter((r) => r.customerId === customerId)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).map(toOrder);
}
save(order: Order): Result<number> {
const current = this.#rows.get(order.id);
if (current === undefined) return fail("NotFound", order.id);
if (current.version !== order.version) {
return fail("VersionConflict", `${order.id} read v${order.version}, stored v${current.version}`);
}
const next = current.version + 1;
this.#rows.set(order.id, toRow(order, next));
order.markStored(next);
return ok(next);
}
}
// The same code works with either repository implementation.
class OrderService {
constructor(private readonly orders: OrderRepository) {}
cancel(orderId: string): Result<number> {
const cancelled = andThen(this.orders.getById(orderId), (o) => o.cancel());
return andThen(cancelled, (o) => this.orders.save(o));
}
openCount(customerId: string): number {
return this.orders.findByCustomer(customerId).filter((o) => o.status === "Open").length;
}
}
const stores: OrderRepository[] = [new LastWriteWinsOrderRepository(), new VersionCheckedOrderRepository()];
for (const repo of stores) {
console.log("== " + repo.constructor.name);
const service = new OrderService(repo);
console.log("add o-1: " + show(repo.add(new Order("o-1", "c-1", 120))));
// After a successful add or save, update this object's version so it can be saved again.
const o2 = new Order("o-2", "c-1", 80);
console.log("add o-2: " + show(repo.add(o2)));
console.log("o-2 add item: " + show(andThen(o2.addItem(5), (o) => repo.save(o))));
console.log("o-2 add item again: " + show(andThen(o2.addItem(5), (o) => repo.save(o))));
console.log("add o-1 again: " + show(repo.add(new Order("o-1", "c-9", 10))));
console.log("cancel o-9: " + show(service.cancel("o-9")));
// Request B reads o-1 first.
const requestB = repo.getById("o-1");
// Request A reads the same order, cancels it and saves.
console.log("A cancel o-1: " + show(service.cancel("o-1")));
// Request B adds an item to its pre-cancellation copy and saves.
const added = andThen(requestB, (o) => o.addItem(30));
console.log("B add item: " + show(andThen(added, (o) => repo.save(o))));
console.log("o-1 now: " + show(repo.getById("o-1")));
console.log("c-1 open orders: " + service.openCount("c-1"));
}== LastWriteWinsOrderRepository
add o-1: ok 1
add o-2: ok 1
o-2 add item: ok 2
o-2 add item again: ok 3
add o-1 again: Duplicate (o-1)
cancel o-9: NotFound (o-9)
A cancel o-1: ok 2
B add item: ok 3
o-1 now: ok o-1 Open total=150 v3
c-1 open orders: 2
== VersionCheckedOrderRepository
add o-1: ok 1
add o-2: ok 1
o-2 add item: ok 2
o-2 add item again: ok 3
add o-1 again: Duplicate (o-1)
cancel o-9: NotFound (o-9)
A cancel o-1: ok 2
B add item: VersionConflict (o-1 read v1, stored v2)
o-1 now: ok o-1 Cancelled total=120 v2
c-1 open orders: 1The output matches C#. Here synchronous code prevents another request from entering between comparison and write. Asynchronous storage I/O allows another request into that gap, producing the same atomicity problem.
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
- Eric Evans. Domain-Driven Design Reference (2015). Domain Language PDF. p. 16, Aggregates. ↩
- Martin Fowler. Patterns of Enterprise Application Architecture (2002). Book page on the author's site. ↩
- Edward Hieatt, Rob Mee. Repository. Martin Fowler, Catalog of Patterns of Enterprise Application Architecture. ↩
- Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley, 2003 (© 2004). Publisher's catalog entry. ↩
- Eric Evans. Domain-Driven Design Reference (2015). Domain Language PDF. Acknowledgments, p. v; Repositories, p. 17. ↩
- Microsoft Learn. Designing the infrastructure persistence layer. .NET Microservices: Architecture for Containerized .NET Applications. Introduction; Define one repository per aggregate. ↩
- Microsoft Learn. DbContext Class. Definition. ↩
- Microsoft Learn. Choosing a testing strategy. EF Core. Repository pattern; Summary. ↩
- Microsoft Learn. Choosing a testing strategy. Introduction; Repository pattern. ↩
- Microsoft Learn. Designing the infrastructure persistence layer. The Repository pattern makes it easier to test your application logic; Repositories shouldn't be mandatory. ↩
- Microsoft Learn. Testing without your Production Database System. Repository pattern. ↩
- Mark Seemann. IQueryable is Tight Coupling (2012). ↩
- Microsoft Learn. Designing the infrastructure persistence layer. Enforce one aggregate root per repository. ↩
- Microsoft Learn. Designing the infrastructure persistence layer. Define one repository per aggregate. ↩
- Microsoft Learn. Handling Concurrency Conflicts. EF Core. Optimistic concurrency. ↩
- David Rice. Optimistic Offline Lock. Martin Fowler, Catalog of Patterns of Enterprise Application Architecture. ↩