У попередній статті ми побудували фундамент — Data Mapper та Repository з ADO.NET. Тепер ми піднімемо архітектуру на наступний рівень, додавши три потужних патерни:
Ці три патерни — не просто академічна теорія. Вони лежать в основі кожного ORM: Entity Framework Core реалізує Identity Map через DbContext, Unit of Work через SaveChanges(), а Specification — через IQueryable<T>. Розуміючи ці патерни в «чистому» ADO.NET, ви зрозумієте, як працює EF Core під капотом.
IBookRepository, SqlRepository<T, TId>, SqlBookRepository. Стаття 9.6. Транзакції.Розглянемо типовий сценарій з поточною реалізацією:
var repository = new SqlBookRepository(connectionString);
// Два виклики FindById з однаковим Id
var book1 = repository.FindById(1);
var book2 = repository.FindById(1);
// Це РІЗНІ об'єкти в пам'яті!
Console.WriteLine(ReferenceEquals(book1, book2)); // false!
// Зміна в book1 НЕ видна в book2
book1!.Borrow();
Console.WriteLine(book1.IsAvailable); // false
Console.WriteLine(book2!.IsAvailable); // true (!)
Кожен виклик FindById виконує SQL-запит та створює новий об'єкт. Це призводить до:
book1 != book2, хоча це одна книга«Ensures that each object gets loaded only once by keeping every loaded object in a map. Looks up objects using the map when referring to them.» — Martin Fowler, P of EAA
Identity Map — це Dictionary<TId, TEntity>, який зберігає всі завантажені сутності. Перед зверненням до бази перевіряємо кеш:
namespace Library.Repository;
/// <summary>
/// Identity Map — кеш завантажених сутностей.
/// Гарантує, що кожна сутність існує в пам'яті лише в одному екземплярі.
/// </summary>
/// <typeparam name="TId">Тип ідентифікатора</typeparam>
/// <typeparam name="TEntity">Тип сутності</typeparam>
public class IdentityMap<TId, TEntity> where TId : notnull
{
private readonly Dictionary<TId, TEntity> _cache = new();
/// <summary>Спроба отримати сутність з кешу.</summary>
public bool TryGet(TId id, out TEntity? entity)
{
return _cache.TryGetValue(id, out entity);
}
/// <summary>Додати/оновити сутність у кеші.</summary>
public void Put(TId id, TEntity entity)
{
_cache[id] = entity;
}
/// <summary>Видалити сутність з кешу.</summary>
public void Remove(TId id)
{
_cache.Remove(id);
}
/// <summary>Очистити весь кеш.</summary>
public void Clear() => _cache.Clear();
/// <summary>Перевірити наявність у кеші.</summary>
public bool Contains(TId id) => _cache.ContainsKey(id);
/// <summary>Кількість закешованих сутностей.</summary>
public int Count => _cache.Count;
}
Доповнимо наш SqlBookRepository підтримкою Identity Map:
using System.Data;
using Microsoft.Data.SqlClient;
using Library.Domain;
namespace Library.Repository.Sql;
/// <summary>
/// SqlBookRepository з підтримкою Identity Map.
/// Кешує завантажені книги для уникнення дублікатів та зайвих SQL-запитів.
/// </summary>
public class CachedSqlBookRepository : SqlBookRepository
{
private readonly IdentityMap<int, Book> _identityMap = new();
public CachedSqlBookRepository(string connectionString)
: base(connectionString) { }
public override Book Save(Book book)
{
var saved = base.Save(book);
_identityMap.Put(saved.Id, saved); // Оновлюємо кеш
return saved;
}
public override Book? FindById(int id)
{
// Спочатку — кеш
if (_identityMap.TryGet(id, out var cached) && cached != null)
{
return cached;
}
// Якщо немає в кеші — SQL
var found = base.FindById(id);
if (found != null)
{
_identityMap.Put(id, found);
}
return found;
}
public override bool DeleteById(int id)
{
_identityMap.Remove(id);
return base.DeleteById(id);
}
/// <summary>Очищає кеш. Корисно для тестування.</summary>
public void ClearCache() => _identityMap.Clear();
}
Тепер повторне завантаження повертає той самий об'єкт:
var repo = new CachedSqlBookRepository(connectionString);
var book1 = repo.FindById(1); // SQL-запит → кеш
var book2 = repo.FindById(1); // Кеш (без SQL)
Console.WriteLine(ReferenceEquals(book1, book2)); // true!
book1!.Borrow();
Console.WriteLine(book2!.IsAvailable); // false — це той самий об'єкт
Поточна реалізація виконує SQL при кожному виклику Save():
// Кожен Save() — окреме з'єднання та окремий SQL
repository.Save(book1); // → SQL INSERT
repository.Save(book2); // → SQL INSERT
repository.Save(book3); // → SQL INSERT
// 3 окремих операції. Якщо друга впаде — перша вже збережена, третя — ні.
Це порушує атомарність: якщо другий INSERT впаде, перший вже збережений, а третій не виконався. Дані в неконсистентному стані.
«Maintains a list of objects affected by a business transaction and coordinates the writing out of changes.» — Martin Fowler, P of EAA
Unit of Work накопичує зміни в пам'яті і зберігає їх одним коммітом у транзакції:
using System.Data;
using Microsoft.Data.SqlClient;
using Library.Domain;
namespace Library.Repository;
/// <summary>
/// Unit of Work — відстежує зміни та зберігає їх у одній транзакції.
/// Використовує SqlTransaction для забезпечення атомарності.
/// </summary>
public class UnitOfWork : IDisposable
{
private readonly string _connectionString;
private readonly IBookRepository _repository;
private readonly List<Book> _newEntities = new();
private readonly List<Book> _dirtyEntities = new();
private readonly List<int> _deletedIds = new();
public UnitOfWork(string connectionString, IBookRepository repository)
{
_connectionString = connectionString;
_repository = repository;
}
/// <summary>Зареєструвати нову сутність для INSERT.</summary>
public void RegisterNew(Book entity)
{
_deletedIds.Remove(entity.Id);
_dirtyEntities.Remove(entity);
_newEntities.Add(entity);
}
/// <summary>Зареєструвати змінену сутність для UPDATE.</summary>
public void RegisterDirty(Book entity)
{
if (!_newEntities.Contains(entity) && !_deletedIds.Contains(entity.Id))
{
_dirtyEntities.Add(entity);
}
}
/// <summary>Зареєструвати сутність для DELETE.</summary>
public void RegisterDeleted(Book entity)
{
if (_newEntities.Remove(entity)) return; // Була нова — просто забираємо
_dirtyEntities.Remove(entity);
_deletedIds.Add(entity.Id);
}
/// <summary>
/// Зберігає ВСІ зміни в одній SQL-транзакції.
/// Або всі операції виконуються успішно, або жодна.
/// </summary>
public void Commit()
{
using SqlConnection connection = new SqlConnection(_connectionString);
connection.Open();
using SqlTransaction transaction = connection.BeginTransaction();
try
{
// INSERT нових
foreach (var entity in _newEntities)
{
InsertInTransaction(entity, connection, transaction);
}
// UPDATE змінених
foreach (var entity in _dirtyEntities)
{
UpdateInTransaction(entity, connection, transaction);
}
// DELETE видалених
foreach (var id in _deletedIds)
{
DeleteInTransaction(id, connection, transaction);
}
transaction.Commit();
Clear();
}
catch
{
transaction.Rollback();
throw;
}
}
/// <summary>Скасувати всі незбережені зміни.</summary>
public void Rollback() => Clear();
/// <summary>Є незбережені зміни?</summary>
public bool HasChanges =>
_newEntities.Count > 0 || _dirtyEntities.Count > 0 || _deletedIds.Count > 0;
/// <summary>Статистика змін.</summary>
public string ChangesSummary =>
$"New: {_newEntities.Count}, Dirty: {_dirtyEntities.Count}, Deleted: {_deletedIds.Count}";
private void Clear()
{
_newEntities.Clear();
_dirtyEntities.Clear();
_deletedIds.Clear();
}
private void InsertInTransaction(Book book, SqlConnection conn, SqlTransaction tx)
{
using SqlCommand cmd = new SqlCommand(@"
INSERT INTO Books (Title, Author, Year, Isbn, IsAvailable)
VALUES (@Title, @Author, @Year, @Isbn, @IsAvailable);
SELECT CAST(SCOPE_IDENTITY() AS INT);", conn, tx);
cmd.Parameters.Add("@Title", SqlDbType.NVarChar, 200).Value = book.Title;
cmd.Parameters.Add("@Author", SqlDbType.NVarChar, 200).Value = book.Author;
cmd.Parameters.Add("@Year", SqlDbType.Int).Value = book.Year;
cmd.Parameters.Add("@Isbn", SqlDbType.NVarChar, 20).Value = book.Isbn;
cmd.Parameters.Add("@IsAvailable", SqlDbType.Bit).Value = book.IsAvailable;
book.Id = (int)cmd.ExecuteScalar()!;
}
private void UpdateInTransaction(Book book, SqlConnection conn, SqlTransaction tx)
{
using SqlCommand cmd = new SqlCommand(@"
UPDATE Books
SET Title = @Title, Author = @Author, Year = @Year,
Isbn = @Isbn, IsAvailable = @IsAvailable
WHERE Id = @Id", conn, tx);
cmd.Parameters.Add("@Id", SqlDbType.Int).Value = book.Id;
cmd.Parameters.Add("@Title", SqlDbType.NVarChar, 200).Value = book.Title;
cmd.Parameters.Add("@Author", SqlDbType.NVarChar, 200).Value = book.Author;
cmd.Parameters.Add("@Year", SqlDbType.Int).Value = book.Year;
cmd.Parameters.Add("@Isbn", SqlDbType.NVarChar, 20).Value = book.Isbn;
cmd.Parameters.Add("@IsAvailable", SqlDbType.Bit).Value = book.IsAvailable;
cmd.ExecuteNonQuery();
}
private void DeleteInTransaction(int id, SqlConnection conn, SqlTransaction tx)
{
using SqlCommand cmd = new SqlCommand(
"DELETE FROM Books WHERE Id = @Id", conn, tx);
cmd.Parameters.Add("@Id", SqlDbType.Int).Value = id;
cmd.ExecuteNonQuery();
}
public void Dispose() => Clear();
}
Розбір коду:
Commit() — відкриває одне з'єднання і одну транзакцію, виконує всі операції послідовно. Якщо будь-яка впаде — catch → Rollback().InsertInTransaction приймає SqlConnection та SqlTransaction ззовні — всі операції працюють в одному контексті.var repository = new SqlBookRepository(connectionString);
using var uow = new UnitOfWork(connectionString, repository);
// Реєструємо зміни (без SQL!)
var book1 = new Book("C# in Depth", "Jon Skeet", 2019, "978-1617294532");
var book2 = new Book("CLR via C#", "Jeffrey Richter", 2012, "978-0735667457");
uow.RegisterNew(book1);
uow.RegisterNew(book2);
// Модифікуємо існуючу книгу
var existing = repository.FindById(1);
if (existing != null)
{
existing.Borrow();
uow.RegisterDirty(existing);
}
Console.WriteLine($"Зміни: {uow.ChangesSummary}");
// New: 2, Dirty: 1, Deleted: 0
// Один Commit = одна транзакція = атомарність!
uow.Commit();
Console.WriteLine("✅ Усі зміни збережено.");
Подивіться на наш IBookRepository:
FindByAuthor(string author)
FindByYear(int year)
FindByTitleContaining(string title)
FindAvailable()
А тепер уявіть, що потрібно знайти «доступні книги автора X, видані після 2020 року». Потрібно додати FindByAuthorAndYearAndAvailable()? А якщо ще й з частиною назви? Кількість комбінацій зростає експоненціально:
| Критеріїв | Комбінацій методів |
|---|---|
| 2 | 4 |
| 4 | 16 |
| 6 | 64 |
«A Specification is a predicate that determines if an object does or does not satisfy some criteria.» — Eric Evans, Domain-Driven Design
Specification — це об'єкт, що представляє умову. Специфікації можна комбінувати через AND, OR, NOT:
namespace Library.Repository.Specification;
/// <summary>
/// Базовий інтерфейс специфікації.
/// Specialty — предикат, що перевіряє, чи відповідає сутність критерію.
/// </summary>
public interface ISpecification<T>
{
bool IsSatisfiedBy(T entity);
}
/// <summary>
/// Абстрактна специфікація з підтримкою комбінування.
/// </summary>
public abstract class Specification<T> : ISpecification<T>
{
public abstract bool IsSatisfiedBy(T entity);
public Specification<T> And(ISpecification<T> other)
=> new AndSpecification<T>(this, other);
public Specification<T> Or(ISpecification<T> other)
=> new OrSpecification<T>(this, other);
public Specification<T> Not()
=> new NotSpecification<T>(this);
}
internal class AndSpecification<T> : Specification<T>
{
private readonly ISpecification<T> _left, _right;
public AndSpecification(ISpecification<T> left, ISpecification<T> right)
{ _left = left; _right = right; }
public override bool IsSatisfiedBy(T entity)
=> _left.IsSatisfiedBy(entity) && _right.IsSatisfiedBy(entity);
}
internal class OrSpecification<T> : Specification<T>
{
private readonly ISpecification<T> _left, _right;
public OrSpecification(ISpecification<T> left, ISpecification<T> right)
{ _left = left; _right = right; }
public override bool IsSatisfiedBy(T entity)
=> _left.IsSatisfiedBy(entity) || _right.IsSatisfiedBy(entity);
}
internal class NotSpecification<T> : Specification<T>
{
private readonly ISpecification<T> _spec;
public NotSpecification(ISpecification<T> spec) { _spec = spec; }
public override bool IsSatisfiedBy(T entity)
=> !_spec.IsSatisfiedBy(entity);
}
using Library.Domain;
namespace Library.Repository.Specification;
/// <summary>
/// Фабрика специфікацій для книг.
/// Статичні методи створюють типові умови пошуку.
/// </summary>
public static class BookSpecifications
{
public static Specification<Book> HasAuthor(string author)
=> new AuthorSpec(author);
public static Specification<Book> TitleContains(string text)
=> new TitleSpec(text);
public static Specification<Book> PublishedIn(int year)
=> new YearSpec(year);
public static Specification<Book> PublishedBetween(int start, int end)
=> new YearRangeSpec(start, end);
public static Specification<Book> IsAvailable()
=> new AvailableSpec();
public static Specification<Book> IsBorrowed()
=> IsAvailable().Not();
}
// Конкретні реалізації
internal class AuthorSpec : Specification<Book>
{
private readonly string _author;
public AuthorSpec(string author) => _author = author;
public override bool IsSatisfiedBy(Book b)
=> b.Author.Contains(_author, StringComparison.OrdinalIgnoreCase);
}
internal class TitleSpec : Specification<Book>
{
private readonly string _text;
public TitleSpec(string text) => _text = text;
public override bool IsSatisfiedBy(Book b)
=> b.Title.Contains(_text, StringComparison.OrdinalIgnoreCase);
}
internal class YearSpec : Specification<Book>
{
private readonly int _year;
public YearSpec(int year) => _year = year;
public override bool IsSatisfiedBy(Book b) => b.Year == _year;
}
internal class YearRangeSpec : Specification<Book>
{
private readonly int _start, _end;
public YearRangeSpec(int start, int end) { _start = start; _end = end; }
public override bool IsSatisfiedBy(Book b) => b.Year >= _start && b.Year <= _end;
}
internal class AvailableSpec : Specification<Book>
{
public override bool IsSatisfiedBy(Book b) => b.IsAvailable;
}
using Library.Repository.Specification;
namespace Library.Repository;
/// <summary>
/// Розширений інтерфейс Repository з підтримкою Specification.
/// </summary>
public interface ISpecificationRepository<TEntity, TId> : IRepository<TEntity, TId>
where TEntity : class
{
IReadOnlyList<TEntity> FindAll(ISpecification<TEntity> spec);
TEntity? FindOne(ISpecification<TEntity> spec);
long Count(ISpecification<TEntity> spec);
bool Exists(ISpecification<TEntity> spec);
}
Реалізація в SqlBookRepository:
// Додаємо до SqlBookRepository:
public IReadOnlyList<Book> FindAll(ISpecification<Book> spec)
{
// Завантажуємо всі книги та фільтруємо в пам'яті
return FindAll()
.Where(spec.IsSatisfiedBy)
.ToList()
.AsReadOnly();
}
public Book? FindOne(ISpecification<Book> spec)
{
return FindAll().FirstOrDefault(spec.IsSatisfiedBy);
}
public long Count(ISpecification<Book> spec)
{
return FindAll().Count(spec.IsSatisfiedBy);
}
public bool Exists(ISpecification<Book> spec)
{
return FindAll().Any(spec.IsSatisfiedBy);
}
using static Library.Repository.Specification.BookSpecifications;
// Прості запити
var martinBooks = repository.FindAll(HasAuthor("Мартін"));
var available = repository.FindAll(IsAvailable());
// Комбіновані запити — без нових методів!
var spec = HasAuthor("Мартін")
.And(PublishedBetween(2000, 2020))
.And(IsAvailable());
var result = repository.FindAll(spec);
// Складніший приклад
var complexSpec = TitleContains("Clean")
.Or(TitleContains("Чистий"))
.And(IsAvailable())
.And(PublishedIn(2008).Not());
var filtered = repository.FindAll(complexSpec);
// Перевірки
bool hasAvailable = repository.Exists(IsAvailable());
long borrowedCount = repository.Count(IsBorrowed());
Переваги:
HasAuthor("X").And(IsAvailable())Entity Framework Core
SaveChanges() = Commit(). ChangeTracker відстежує Added/Modified/Deleted — так само, як наш UoW.Dapper
NHibernate
Session), Unit of Work (Transaction), Specification (Criteria API).Ваш ADO.NET код
Реалізуйте CachedSqlCustomerRepository:
Customer (Id, Name, Email).FindById спочатку перевіряє кеш.Save оновлює кеш.ClearCache().Створіть специфікації:
HasEmail(string pattern) — пошук за email.RegisteredAfter(DateTime date) — зареєстровані після дати.HasEmail("@gmail.com").And(RegisteredAfter(new DateTime(2024,1,1))).Реалізуйте бізнес-сценарій:
UnitOfWork.Commit().Зробіть UnitOfWork<TEntity, TId> generic:
IRepository<TEntity, TId>.RegisterNew, RegisterDirty, RegisterDeleted.Commit() з SqlTransaction.Book та Customer.Розширте Specification Pattern:
ISqlSpecification<T> з методом string ToWhereClause().HasAuthor("X") → WHERE Author LIKE '%X%'.And(spec1, spec2) → WHERE (condition1) AND (condition2).Побудуйте повноцінну систему:
LibraryService залежить від IBookRepository та ICustomerRepository.UnitOfWork координує зміни.Identity Map кешує сутності.Specification для пошуку.InMemoryBookRepository : IBookRepository для тестів.Identity Map
Dictionary<TId, TEntity>. Гарантія: один об'єкт у базі = один об'єкт у пам'яті. Основа ChangeTracker в EF Core.Unit of Work
Specification Pattern
Шлях до ORM
SqlConnection.Open() до архітектурних патернів рівня Enterprise. Ви розумієте, як ORM працює «під капотом». Наступний крок — вивчити Entity Framework Core, де ці патерни вже реалізовані за вас, і зосередитися на бізнес-логіці замість інфраструктури.