Repository Pattern та Unit of Work
Repository Pattern та Unit of Work
У попередній статті ми навчилися працювати з SQLite через DbContext безпосередньо у ViewModel. Це працює для простих додатків, але швидко стає проблемою:
- ViewModel знає про DbContext (порушення абстракції)
- Складно тестувати (потрібна реальна база даних)
- Дублювання коду (той самий запит у кількох ViewModels)
- Важко змінити storage (з SQLite на PostgreSQL або API)
Рішення — Repository Pattern. Репозиторій — це абстракція над доступом до даних. ViewModel працює з IUserRepository, не знаючи, що всередині — DbContext, API, або файли. Це дає тестованість (mock репозиторій), гнучкість (заміна реалізації), та чистоту коду.
Unit of Work координує роботу кількох репозиторіїв у одній транзакції. Замість SaveChangesAsync() після кожної операції, ви викликаєте UnitOfWork.CommitAsync() один раз для всіх змін.
У цій статті ми побудуємо повноцінну архітектуру: IRepository<T> → GenericRepository<T> → IUserRepository → UserRepository, та IUnitOfWork для координації. Також розглянемо User Settings через JSON для зберігання налаштувань додатку.
Чому Repository Pattern?
Проблема: ViewModel залежить від DbContext
// ❌ Погана практика
public class UserListViewModel : ViewModelBase
{
private readonly AppDbContext _context;
public UserListViewModel(AppDbContext context)
{
_context = context;
}
private async Task LoadUsersAsync()
{
// ViewModel знає про EF Core, LINQ, Include
var users = await _context.Users
.Include(u => u.Tasks)
.Where(u => u.IsActive)
.OrderBy(u => u.Name)
.ToListAsync();
Users = new ObservableCollection<User>(users);
}
}
Проблеми:
- Порушення абстракції — ViewModel знає про DbContext, EF Core, LINQ
- Важко тестувати — потрібна реальна база даних для unit-тестів
- Дублювання коду — той самий запит у кількох ViewModels
- Важко змінити storage — якщо потрібно API замість SQLite, треба міняти всі ViewModels
Рішення: Repository як абстракція
// ✅ Хороша практика
public class UserListViewModel : ViewModelBase
{
private readonly IUserRepository _userRepository;
public UserListViewModel(IUserRepository userRepository)
{
_userRepository = userRepository;
}
private async Task LoadUsersAsync()
{
// ViewModel не знає про DbContext, EF Core, LINQ
var users = await _userRepository.GetActiveUsersAsync();
Users = new ObservableCollection<User>(users);
}
}
Переваги:
- Абстракція — ViewModel працює з інтерфейсом, не знаючи про реалізацію
- Тестованість — легко замінити на mock репозиторій
- Повторне використання —
GetActiveUsersAsync()в одному місці - Гнучкість — легко змінити реалізацію (SQLite → API → In-Memory)
Ієрархія Repository Pattern
Структура
IRepository<T> // Базовий інтерфейс з CRUD
↓
GenericRepository<T> // Базова реалізація через DbContext
↓
IUserRepository // Специфічний інтерфейс для User
↓
UserRepository // Реалізація з додатковими методами
1. IRepository: базовий інтерфейс
public interface IRepository<T> where T : class
{
// Читання
Task<T?> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);
// Запис
Task AddAsync(T entity);
Task AddRangeAsync(IEnumerable<T> entities);
// Оновлення
void Update(T entity);
void UpdateRange(IEnumerable<T> entities);
// Видалення
void Remove(T entity);
void RemoveRange(IEnumerable<T> entities);
// Підрахунок
Task<int> CountAsync();
Task<int> CountAsync(Expression<Func<T, bool>> predicate);
// Перевірка існування
Task<bool> AnyAsync(Expression<Func<T, bool>> predicate);
}
Чому деякі методи не async?
Update(),Remove()— лише маркують сутність як змінену/видалену- Реальне збереження відбувається при
SaveChangesAsync()(через Unit of Work)
2. GenericRepository: базова реалізація
public class GenericRepository<T> : IRepository<T> where T : class
{
protected readonly AppDbContext _context;
protected readonly DbSet<T> _dbSet;
public GenericRepository(AppDbContext context)
{
_context = context;
_dbSet = context.Set<T>();
}
public virtual async Task<T?> GetByIdAsync(int id)
{
return await _dbSet.FindAsync(id);
}
public virtual async Task<IEnumerable<T>> GetAllAsync()
{
return await _dbSet.ToListAsync();
}
public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate)
{
return await _dbSet.Where(predicate).ToListAsync();
}
public virtual async Task AddAsync(T entity)
{
await _dbSet.AddAsync(entity);
}
public virtual async Task AddRangeAsync(IEnumerable<T> entities)
{
await _dbSet.AddRangeAsync(entities);
}
public virtual void Update(T entity)
{
_dbSet.Update(entity);
}
public virtual void UpdateRange(IEnumerable<T> entities)
{
_dbSet.UpdateRange(entities);
}
public virtual void Remove(T entity)
{
_dbSet.Remove(entity);
}
public virtual void RemoveRange(IEnumerable<T> entities)
{
_dbSet.RemoveRange(entities);
}
public virtual async Task<int> CountAsync()
{
return await _dbSet.CountAsync();
}
public virtual async Task<int> CountAsync(Expression<Func<T, bool>> predicate)
{
return await _dbSet.CountAsync(predicate);
}
public virtual async Task<bool> AnyAsync(Expression<Func<T, bool>> predicate)
{
return await _dbSet.AnyAsync(predicate);
}
}
Чому virtual?
- Дозволяє перевизначити методи у специфічних репозиторіях
- Наприклад,
GetByIdAsync()може включати пов'язані дані черезInclude()
3. IUserRepository: специфічний інтерфейс
public interface IUserRepository : IRepository<User>
{
// Додаткові методи, специфічні для User
Task<User?> GetByEmailAsync(string email);
Task<IEnumerable<User>> GetActiveUsersAsync();
Task<IEnumerable<User>> GetUsersWithTasksAsync();
Task<User?> GetUserWithTasksAsync(int userId);
Task<bool> EmailExistsAsync(string email);
Task<IEnumerable<User>> SearchUsersAsync(string searchTerm);
Task<IEnumerable<User>> GetUsersPageAsync(int pageNumber, int pageSize);
}
Чому окремий інтерфейс?
- Додаткові методи, що не підходять для
IRepository<T> - Бізнес-логіка, специфічна для User
- Можливість mock-ати лише потрібні методи у тестах
4. UserRepository: реалізація
public class UserRepository : GenericRepository<User>, IUserRepository
{
public UserRepository(AppDbContext context) : base(context)
{
}
// Перевизначення базового методу з Include
public override async Task<User?> GetByIdAsync(int id)
{
return await _dbSet
.Include(u => u.Tasks)
.FirstOrDefaultAsync(u => u.Id == id);
}
public async Task<User?> GetByEmailAsync(string email)
{
return await _dbSet
.FirstOrDefaultAsync(u => u.Email == email);
}
public async Task<IEnumerable<User>> GetActiveUsersAsync()
{
return await _dbSet
.Where(u => u.IsActive)
.OrderBy(u => u.Name)
.ToListAsync();
}
public async Task<IEnumerable<User>> GetUsersWithTasksAsync()
{
return await _dbSet
.Include(u => u.Tasks)
.OrderBy(u => u.Name)
.ToListAsync();
}
public async Task<User?> GetUserWithTasksAsync(int userId)
{
return await _dbSet
.Include(u => u.Tasks)
.FirstOrDefaultAsync(u => u.Id == userId);
}
public async Task<bool> EmailExistsAsync(string email)
{
return await _dbSet.AnyAsync(u => u.Email == email);
}
public async Task<IEnumerable<User>> SearchUsersAsync(string searchTerm)
{
return await _dbSet
.Where(u => u.Name.Contains(searchTerm) || u.Email.Contains(searchTerm))
.OrderBy(u => u.Name)
.ToListAsync();
}
public async Task<IEnumerable<User>> GetUsersPageAsync(int pageNumber, int pageSize)
{
return await _dbSet
.OrderBy(u => u.Name)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
}
5. TaskRepository: ще один приклад
public interface ITaskRepository : IRepository<TaskItem>
{
Task<IEnumerable<TaskItem>> GetTasksByUserIdAsync(int userId);
Task<IEnumerable<TaskItem>> GetCompletedTasksAsync();
Task<IEnumerable<TaskItem>> GetPendingTasksAsync();
Task<IEnumerable<TaskItem>> GetOverdueTasksAsync();
Task<int> GetCompletedTasksCountAsync(int userId);
}
public class TaskRepository : GenericRepository<TaskItem>, ITaskRepository
{
public TaskRepository(AppDbContext context) : base(context)
{
}
public async Task<IEnumerable<TaskItem>> GetTasksByUserIdAsync(int userId)
{
return await _dbSet
.Where(t => t.UserId == userId)
.OrderBy(t => t.DueDate)
.ToListAsync();
}
public async Task<IEnumerable<TaskItem>> GetCompletedTasksAsync()
{
return await _dbSet
.Where(t => t.IsCompleted)
.OrderByDescending(t => t.CompletedAt)
.ToListAsync();
}
public async Task<IEnumerable<TaskItem>> GetPendingTasksAsync()
{
return await _dbSet
.Where(t => !t.IsCompleted)
.OrderBy(t => t.DueDate)
.ToListAsync();
}
public async Task<IEnumerable<TaskItem>> GetOverdueTasksAsync()
{
var now = DateTime.UtcNow;
return await _dbSet
.Where(t => !t.IsCompleted && t.DueDate < now)
.OrderBy(t => t.DueDate)
.ToListAsync();
}
public async Task<int> GetCompletedTasksCountAsync(int userId)
{
return await _dbSet
.CountAsync(t => t.UserId == userId && t.IsCompleted);
}
}
Unit of Work: координація репозиторіїв
Unit of Work координує роботу кількох репозиторіїв у одній транзакції.
IUnitOfWork: інтерфейс
public interface IUnitOfWork : IDisposable
{
// Репозиторії
IUserRepository Users { get; }
ITaskRepository Tasks { get; }
// Збереження змін
Task<int> CommitAsync();
Task<int> CommitAsync(CancellationToken cancellationToken);
// Транзакції
Task BeginTransactionAsync();
Task CommitTransactionAsync();
Task RollbackTransactionAsync();
}
UnitOfWork: реалізація
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
private IDbContextTransaction? _transaction;
// Lazy initialization для репозиторіїв
private IUserRepository? _userRepository;
private ITaskRepository? _taskRepository;
public UnitOfWork(AppDbContext context)
{
_context = context;
}
public IUserRepository Users
{
get
{
_userRepository ??= new UserRepository(_context);
return _userRepository;
}
}
public ITaskRepository Tasks
{
get
{
_taskRepository ??= new TaskRepository(_context);
return _taskRepository;
}
}
public async Task<int> CommitAsync()
{
return await _context.SaveChangesAsync();
}
public async Task<int> CommitAsync(CancellationToken cancellationToken)
{
return await _context.SaveChangesAsync(cancellationToken);
}
public async Task BeginTransactionAsync()
{
_transaction = await _context.Database.BeginTransactionAsync();
}
public async Task CommitTransactionAsync()
{
if (_transaction == null)
throw new InvalidOperationException("Transaction not started");
try
{
await _context.SaveChangesAsync();
await _transaction.CommitAsync();
}
catch
{
await RollbackTransactionAsync();
throw;
}
finally
{
await _transaction.DisposeAsync();
_transaction = null;
}
}
public async Task RollbackTransactionAsync()
{
if (_transaction != null)
{
await _transaction.RollbackAsync();
await _transaction.DisposeAsync();
_transaction = null;
}
}
public void Dispose()
{
_transaction?.Dispose();
_context.Dispose();
}
}
Реєстрація у DI
private void ConfigureServices(IServiceCollection services)
{
// DbContext
services.AddDbContext<AppDbContext>(options =>
{
var dbPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"MyApp",
"app.db"
);
options.UseSqlite($"Data Source={dbPath}");
});
// Unit of Work (Scoped)
services.AddScoped<IUnitOfWork, UnitOfWork>();
// Або окремі репозиторії (якщо не використовуєте UoW)
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<ITaskRepository, TaskRepository>();
// ViewModels
services.AddTransient<MainViewModel>();
}
Використання у ViewModel
Приклад 1: Базові CRUD операції
public class UserListViewModel : ViewModelBase
{
private readonly IUnitOfWork _unitOfWork;
private ObservableCollection<User> _users = new();
public UserListViewModel(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
LoadUsersCommand = new AsyncRelayCommand(LoadUsersAsync);
AddUserCommand = new AsyncRelayCommand(AddUserAsync);
UpdateUserCommand = new AsyncRelayCommand<User>(UpdateUserAsync);
DeleteUserCommand = new AsyncRelayCommand<User>(DeleteUserAsync);
}
public ObservableCollection<User> Users
{
get => _users;
set => SetProperty(ref _users, value);
}
public IAsyncRelayCommand LoadUsersCommand { get; }
public IAsyncRelayCommand AddUserCommand { get; }
public IAsyncRelayCommand<User> UpdateUserCommand { get; }
public IAsyncRelayCommand<User> DeleteUserCommand { get; }
private async Task LoadUsersAsync()
{
var users = await _unitOfWork.Users.GetActiveUsersAsync();
Users = new ObservableCollection<User>(users);
}
private async Task AddUserAsync()
{
var newUser = new User
{
Name = "New User",
Email = $"user{DateTime.Now.Ticks}@example.com",
IsActive = true,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Users.AddAsync(newUser);
await _unitOfWork.CommitAsync();
Users.Add(newUser);
}
private async Task UpdateUserAsync(User? user)
{
if (user == null) return;
user.UpdatedAt = DateTime.UtcNow;
_unitOfWork.Users.Update(user);
await _unitOfWork.CommitAsync();
}
private async Task DeleteUserAsync(User? user)
{
if (user == null) return;
_unitOfWork.Users.Remove(user);
await _unitOfWork.CommitAsync();
Users.Remove(user);
}
}
Приклад 2: Транзакції через UoW
public class TransferTasksViewModel : ViewModelBase
{
private readonly IUnitOfWork _unitOfWork;
public TransferTasksViewModel(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
TransferCommand = new AsyncRelayCommand(TransferTasksAsync);
}
public int FromUserId { get; set; }
public int ToUserId { get; set; }
public IAsyncRelayCommand TransferCommand { get; }
private async Task TransferTasksAsync()
{
try
{
// Початок транзакції
await _unitOfWork.BeginTransactionAsync();
// Отримуємо задачі користувача
var tasks = await _unitOfWork.Tasks.GetTasksByUserIdAsync(FromUserId);
// Переносимо на іншого користувача
foreach (var task in tasks)
{
task.UserId = ToUserId;
_unitOfWork.Tasks.Update(task);
}
// Commit транзакції
await _unitOfWork.CommitTransactionAsync();
await ShowSuccessAsync($"Transferred {tasks.Count()} tasks");
}
catch (Exception ex)
{
await _unitOfWork.RollbackTransactionAsync();
await ShowErrorAsync($"Transfer failed: {ex.Message}");
}
}
}
Приклад 3: Складні операції з кількома репозиторіями
public class UserDetailsViewModel : ViewModelBase
{
private readonly IUnitOfWork _unitOfWork;
private User? _selectedUser;
private ObservableCollection<TaskItem> _userTasks = new();
public UserDetailsViewModel(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
LoadUserCommand = new AsyncRelayCommand<int>(LoadUserAsync);
CompleteAllTasksCommand = new AsyncRelayCommand(CompleteAllTasksAsync);
}
public User? SelectedUser
{
get => _selectedUser;
set => SetProperty(ref _selectedUser, value);
}
public ObservableCollection<TaskItem> UserTasks
{
get => _userTasks;
set => SetProperty(ref _userTasks, value);
}
public IAsyncRelayCommand<int> LoadUserCommand { get; }
public IAsyncRelayCommand CompleteAllTasksCommand { get; }
private async Task LoadUserAsync(int userId)
{
// Завантажуємо користувача
SelectedUser = await _unitOfWork.Users.GetByIdAsync(userId);
if (SelectedUser == null) return;
// Завантажуємо його задачі
var tasks = await _unitOfWork.Tasks.GetTasksByUserIdAsync(userId);
UserTasks = new ObservableCollection<TaskItem>(tasks);
}
private async Task CompleteAllTasksAsync()
{
if (SelectedUser == null) return;
try
{
await _unitOfWork.BeginTransactionAsync();
// Позначаємо всі задачі як виконані
foreach (var task in UserTasks.Where(t => !t.IsCompleted))
{
task.IsCompleted = true;
task.CompletedAt = DateTime.UtcNow;
_unitOfWork.Tasks.Update(task);
}
// Оновлюємо статистику користувача
SelectedUser.CompletedTasksCount = await _unitOfWork.Tasks
.GetCompletedTasksCountAsync(SelectedUser.Id);
_unitOfWork.Users.Update(SelectedUser);
await _unitOfWork.CommitTransactionAsync();
// Оновлюємо UI
OnPropertyChanged(nameof(UserTasks));
}
catch (Exception ex)
{
await _unitOfWork.RollbackTransactionAsync();
await ShowErrorAsync($"Failed to complete tasks: {ex.Message}");
}
}
}
Best Practices для Repository Pattern
1. Не робіть Repository занадто загальним
❌ Погано:
// Занадто загальний метод
Task<IEnumerable<T>> GetAsync(
Expression<Func<T, bool>>? filter = null,
Func<IQueryable<T>, IOrderedQueryable<T>>? orderBy = null,
string includeProperties = "",
int? skip = null,
int? take = null
);
✅ Добре:
// Специфічні методи з чіткою метою
Task<IEnumerable<User>> GetActiveUsersAsync();
Task<IEnumerable<User>> GetUsersPageAsync(int pageNumber, int pageSize);
Task<User?> GetUserWithTasksAsync(int userId);
2. Не повертайте IQueryable
❌ Погано:
public IQueryable<User> GetUsers()
{
return _dbSet.AsQueryable(); // Витік абстракції
}
// ViewModel може робити що завгодно
var users = repository.GetUsers()
.Include(u => u.Tasks)
.Where(u => u.IsActive)
.ToList(); // ViewModel знає про EF Core!
✅ Добре:
public async Task<IEnumerable<User>> GetActiveUsersAsync()
{
return await _dbSet
.Where(u => u.IsActive)
.ToListAsync(); // Повертаємо матеріалізовану колекцію
}
3. Використовуйте Expression<Func<T, bool>> для гнучких фільтрів
public async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate)
{
return await _dbSet.Where(predicate).ToListAsync();
}
// Використання
var activeUsers = await repository.FindAsync(u => u.IsActive);
var usersByEmail = await repository.FindAsync(u => u.Email.Contains("@example.com"));
4. Lazy initialization для репозиторіїв у UoW
public class UnitOfWork : IUnitOfWork
{
private IUserRepository? _userRepository;
public IUserRepository Users
{
get
{
// Створюємо лише при першому доступі
_userRepository ??= new UserRepository(_context);
return _userRepository;
}
}
}
5. Не змішуйте бізнес-логіку з доступом до даних
❌ Погано:
public async Task<User> CreateUserAsync(string name, string email)
{
// Бізнес-логіка у репозиторії
if (string.IsNullOrEmpty(name))
throw new ArgumentException("Name is required");
if (await EmailExistsAsync(email))
throw new InvalidOperationException("Email already exists");
var user = new User { Name = name, Email = email };
await AddAsync(user);
await _context.SaveChangesAsync();
return user;
}
✅ Добре:
// Репозиторій — лише доступ до даних
public async Task<bool> EmailExistsAsync(string email)
{
return await _dbSet.AnyAsync(u => u.Email == email);
}
// Бізнес-логіка у Service або ViewModel
public class UserService
{
private readonly IUnitOfWork _unitOfWork;
public async Task<User> CreateUserAsync(string name, string email)
{
if (string.IsNullOrEmpty(name))
throw new ArgumentException("Name is required");
if (await _unitOfWork.Users.EmailExistsAsync(email))
throw new InvalidOperationException("Email already exists");
var user = new User { Name = name, Email = email };
await _unitOfWork.Users.AddAsync(user);
await _unitOfWork.CommitAsync();
return user;
}
}
6. Використовуйте Specification Pattern для складних запитів
public interface ISpecification<T>
{
Expression<Func<T, bool>> Criteria { get; }
List<Expression<Func<T, object>>> Includes { get; }
Expression<Func<T, object>>? OrderBy { get; }
Expression<Func<T, object>>? OrderByDescending { get; }
}
public class ActiveUsersSpecification : ISpecification<User>
{
public Expression<Func<User, bool>> Criteria => u => u.IsActive;
public List<Expression<Func<User, object>>> Includes { get; } = new();
public Expression<Func<User, object>>? OrderBy => u => u.Name;
public Expression<Func<User, object>>? OrderByDescending => null;
}
// Використання
public async Task<IEnumerable<T>> FindAsync(ISpecification<T> specification)
{
var query = _dbSet.AsQueryable();
if (specification.Criteria != null)
query = query.Where(specification.Criteria);
query = specification.Includes
.Aggregate(query, (current, include) => current.Include(include));
if (specification.OrderBy != null)
query = query.OrderBy(specification.OrderBy);
if (specification.OrderByDescending != null)
query = query.OrderByDescending(specification.OrderByDescending);
return await query.ToListAsync();
}
User Settings: зберігання налаштувань
Варіант 1: JSON файл (рекомендовано)
public class AppSettings
{
public string Theme { get; set; } = "Light";
public string Language { get; set; } = "en";
public bool AutoSave { get; set; } = true;
public int AutoSaveInterval { get; set; } = 300; // секунди
public WindowSettings Window { get; set; } = new();
}
public class WindowSettings
{
public double Width { get; set; } = 1024;
public double Height { get; set; } = 768;
public double Left { get; set; } = 100;
public double Top { get; set; } = 100;
public bool IsMaximized { get; set; } = false;
}
SettingsService: збереження та завантаження
public interface ISettingsService
{
Task<AppSettings> LoadAsync();
Task SaveAsync(AppSettings settings);
AppSettings Current { get; }
}
public class SettingsService : ISettingsService
{
private readonly string _settingsPath;
private AppSettings _current;
public SettingsService()
{
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var appFolder = Path.Combine(appDataPath, "MyApp");
Directory.CreateDirectory(appFolder);
_settingsPath = Path.Combine(appFolder, "settings.json");
_current = new AppSettings();
}
public AppSettings Current => _current;
public async Task<AppSettings> LoadAsync()
{
try
{
if (File.Exists(_settingsPath))
{
var json = await File.ReadAllTextAsync(_settingsPath);
_current = JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
}
else
{
_current = new AppSettings();
await SaveAsync(_current);
}
}
catch (Exception ex)
{
// Логування помилки
Console.WriteLine($"Failed to load settings: {ex.Message}");
_current = new AppSettings();
}
return _current;
}
public async Task SaveAsync(AppSettings settings)
{
try
{
_current = settings;
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var json = JsonSerializer.Serialize(settings, options);
await File.WriteAllTextAsync(_settingsPath, json);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to save settings: {ex.Message}");
throw;
}
}
}
Реєстрація у DI
private void ConfigureServices(IServiceCollection services)
{
// Settings як Singleton
services.AddSingleton<ISettingsService, SettingsService>();
// ViewModels
services.AddTransient<SettingsViewModel>();
}
Використання у ViewModel
public class SettingsViewModel : ViewModelBase
{
private readonly ISettingsService _settingsService;
private AppSettings _settings;
public SettingsViewModel(ISettingsService settingsService)
{
_settingsService = settingsService;
_settings = settingsService.Current;
SaveCommand = new AsyncRelayCommand(SaveAsync);
ResetCommand = new RelayCommand(Reset);
}
public string Theme
{
get => _settings.Theme;
set
{
if (_settings.Theme != value)
{
_settings.Theme = value;
OnPropertyChanged();
}
}
}
public bool AutoSave
{
get => _settings.AutoSave;
set
{
if (_settings.AutoSave != value)
{
_settings.AutoSave = value;
OnPropertyChanged();
}
}
}
public IAsyncRelayCommand SaveCommand { get; }
public IRelayCommand ResetCommand { get; }
private async Task SaveAsync()
{
await _settingsService.SaveAsync(_settings);
await ShowSuccessAsync("Settings saved");
}
private void Reset()
{
_settings = new AppSettings();
OnPropertyChanged(nameof(Theme));
OnPropertyChanged(nameof(AutoSave));
}
}
Збереження розміру вікна
public partial class MainWindow : Window
{
private readonly ISettingsService _settingsService;
public MainWindow(MainViewModel viewModel, ISettingsService settingsService)
{
InitializeComponent();
DataContext = viewModel;
_settingsService = settingsService;
Loaded += MainWindow_Loaded;
Closing += MainWindow_Closing;
}
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
var settings = await _settingsService.LoadAsync();
Width = settings.Window.Width;
Height = settings.Window.Height;
Left = settings.Window.Left;
Top = settings.Window.Top;
if (settings.Window.IsMaximized)
WindowState = WindowState.Maximized;
}
private async void MainWindow_Closing(object? sender, CancelEventArgs e)
{
var settings = _settingsService.Current;
if (WindowState == WindowState.Maximized)
{
settings.Window.IsMaximized = true;
}
else
{
settings.Window.Width = Width;
settings.Window.Height = Height;
settings.Window.Left = Left;
settings.Window.Top = Top;
settings.Window.IsMaximized = false;
}
await _settingsService.SaveAsync(settings);
}
}
Варіант 2: IOptions через Microsoft.Extensions.Options
// Встановлення пакету
// dotnet add package Microsoft.Extensions.Options
// Реєстрація
services.Configure<AppSettings>(configuration.GetSection("AppSettings"));
// Використання
public class SettingsViewModel : ViewModelBase
{
private readonly IOptions<AppSettings> _options;
public SettingsViewModel(IOptions<AppSettings> options)
{
_options = options;
}
public string Theme => _options.Value.Theme;
}
Переваги IOptions:
- Інтеграція з конфігураційною системою .NET
- Підтримка hot-reload (IOptionsMonitor)
- Валідація через Data Annotations
Недоліки:
- Складніше для простих сценаріїв
- Потребує appsettings.json
Асинхронність: чому async важливий у десктопі
Проблема: блокування UI Thread
// ❌ Синхронний код блокує UI
private void LoadUsers_Click(object sender, RoutedEventArgs e)
{
var users = _context.Users.ToList(); // Блокує UI на час запиту
UsersListBox.ItemsSource = users;
}
Що відбувається:
- Користувач натискає кнопку
- UI Thread блокується на час запиту до бази даних
- Вікно "зависає" (не реагує на клік, не можна перемістити)
- Погана user experience
Рішення: async/await
// ✅ Асинхронний код не блокує UI
private async void LoadUsers_Click(object sender, RoutedEventArgs e)
{
var users = await _context.Users.ToListAsync(); // Не блокує UI
UsersListBox.ItemsSource = users;
}
Що відбувається:
- Користувач натискає кнопку
- Запит до бази даних виконується асинхронно
- UI Thread вільний (вікно реагує на клік, можна перемістити)
- Коли дані готові, UI оновлюється
- Хороша user experience
Всі EF Core методи мають async версії
// Синхронні (блокують UI)
var users = context.Users.ToList();
var user = context.Users.Find(id);
var count = context.Users.Count();
context.SaveChanges();
// Асинхронні (не блокують UI)
var users = await context.Users.ToListAsync();
var user = await context.Users.FindAsync(id);
var count = await context.Users.CountAsync();
await context.SaveChangesAsync();
Практичні завдання
Рівень 1: Generic Repository з In-Memory реалізацією
Мета: Зрозуміти структуру Repository Pattern без прив'язки до бази даних.
Завдання:
Створіть In-Memory репозиторій для TodoItem:
- Структура:
TodoItemмодель (Id, Title, IsCompleted, CreatedAt)IRepository<T>інтерфейсInMemoryRepository<T>реалізація черезList<T>
- Функціональність:
- GetAll, GetById, Add, Update, Remove
- Дані зберігаються у
List<T>(не база даних) - Id генерується автоматично
- Тестування:
- Створіть unit-тести для репозиторію
- Перевірте всі CRUD операції
Критерії успіху:
- IRepository
визначено правильно - InMemoryRepository
працює - Всі тести проходять
- Код не залежить від бази даних
Підказка:
public class InMemoryRepository<T> : IRepository<T> where T : class, IEntity
{
private readonly List<T> _data = new();
private int _nextId = 1;
public Task<T?> GetByIdAsync(int id)
{
var entity = _data.FirstOrDefault(e => e.Id == id);
return Task.FromResult(entity);
}
public Task<IEnumerable<T>> GetAllAsync()
{
return Task.FromResult<IEnumerable<T>>(_data.ToList());
}
public Task AddAsync(T entity)
{
entity.Id = _nextId++;
_data.Add(entity);
return Task.CompletedTask;
}
// ... інші методи
}
Рівень 2: Repository через SQLite + DbContext
Мета: Реалізувати повноцінний Repository Pattern з SQLite.
Завдання:
Створіть додаток для управління книгами:
- Структура:
Bookмодель (Id, Title, Author, ISBN, PublishedYear)IRepository<T>таGenericRepository<T>IBookRepositoryтаBookRepositoryAppDbContextзDbSet<Book>
- Додаткові методи у IBookRepository:
GetBooksByAuthorAsync(string author)SearchBooksAsync(string searchTerm)GetBooksPageAsync(int pageNumber, int pageSize)IsbnExistsAsync(string isbn)
- ViewModel:
- BookListViewModel з CRUD операціями
- Пошук та фільтрація
- Пагінація
Критерії успіху:
- Repository Pattern реалізовано правильно
- SQLite підключено
- Всі CRUD операції працюють
- Додаткові методи працюють
Рівень 3: Повний CRUD з UoW, Repository, Settings
Мета: Побудувати повноцінний додаток з чистою архітектурою.
Завдання:
Створіть Task Manager додаток:
- Структура даних:
User(Id, Name, Email)TaskItem(Id, Title, Description, DueDate, IsCompleted, UserId)- Зв'язок один-до-багатьох
- Repository Pattern:
IRepository<T>,GenericRepository<T>IUserRepository,UserRepositoryITaskRepository,TaskRepositoryIUnitOfWork,UnitOfWork
- Settings:
- Theme (Light/Dark)
- DefaultView (List/Grid)
- AutoSave (bool)
- Збереження розміру вікна
- Функціональність:
- CRUD для користувачів та задач
- Фільтрація (Active/Completed/Overdue)
- Пошук
- Транзакція для переносу задач між користувачами
- Зміна теми через Settings
Критерії успіху:
- Повна архітектура (UoW + Repository)
- Всі CRUD операції працюють
- Транзакції працюють правильно
- Settings зберігаються між запусками
- Тема змінюється динамічно
Підказка (структура проєкту):
MyApp/
├── Models/
│ ├── User.cs
│ └── TaskItem.cs
├── Data/
│ ├── AppDbContext.cs
│ ├── Repositories/
│ │ ├── IRepository.cs
│ │ ├── GenericRepository.cs
│ │ ├── IUserRepository.cs
│ │ ├── UserRepository.cs
│ │ ├── ITaskRepository.cs
│ │ └── TaskRepository.cs
│ └── UnitOfWork/
│ ├── IUnitOfWork.cs
│ └── UnitOfWork.cs
├── Services/
│ ├── ISettingsService.cs
│ └── SettingsService.cs
├── ViewModels/
│ ├── MainViewModel.cs
│ ├── UserListViewModel.cs
│ ├── TaskListViewModel.cs
│ └── SettingsViewModel.cs
└── Views/
├── MainWindow.xaml
├── UserListView.xaml
├── TaskListView.xaml
└── SettingsView.xaml
Підсумок
Repository Pattern та Unit of Work створюють чисту архітектуру доступу до даних. ViewModel не знає про DbContext, легко тестується через mock-репозиторії, бізнес-логіка відокремлена від доступу до даних.
Ключові висновки:
🏗️ Ієрархія
🔄 Unit of Work
🧪 Тестованість
⚡ Async/Await
⚙️ Settings
📐 Best Practices
Що далі?
Наступна стаття — ViewModel Testing покаже, як тестувати ViewModels з mock-репозиторіями.
Словник термінів
Додаткові ресурси
SQLite та EF Core у десктопних додатках
Локальне зберігання даних через SQLite. DbContext, міграції, seeding. Інтеграція з DI. IDbContextFactory для багатопоточності.
Тестування ViewModels
Unit-тести для MVVM. xUnit, NSubstitute для mock-об'єктів. Тестування Properties, Commands, Validation, Messenger. Arrange-Act-Assert pattern.