Desktop UI

Repository Pattern та Unit of Work

Чиста архітектура доступу до даних. IRepository<T>, GenericRepository, специфічні репозиторії. Unit of Work для координації транзакцій. User Settings через JSON.

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>IUserRepositoryUserRepository, та IUnitOfWork для координації. Також розглянемо User Settings через JSON для зберігання налаштувань додатку.

Словник теми:Repository Pattern — патерн для абстракції доступу до даних. Generic Repository — базовий репозиторій з CRUD операціями для будь-якої сутності. Specific Repository — репозиторій для конкретної сутності з додатковими методами. Unit of Work (UoW) — патерн для координації роботи кількох репозиторіїв у одній транзакції. Aggregate Root — головна сутність, через яку відбувається доступ до пов'язаних сутностей.

Чому 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);
    }
}

Проблеми:

  1. Порушення абстракції — ViewModel знає про DbContext, EF Core, LINQ
  2. Важко тестувати — потрібна реальна база даних для unit-тестів
  3. Дублювання коду — той самий запит у кількох ViewModels
  4. Важко змінити 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);
    }
}

Переваги:

  1. Абстракція — ViewModel працює з інтерфейсом, не знаючи про реалізацію
  2. Тестованість — легко замінити на mock репозиторій
  3. Повторне використанняGetActiveUsersAsync() в одному місці
  4. Гнучкість — легко змінити реалізацію (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;
}

Що відбувається:

  1. Користувач натискає кнопку
  2. UI Thread блокується на час запиту до бази даних
  3. Вікно "зависає" (не реагує на клік, не можна перемістити)
  4. Погана user experience

Рішення: async/await

// ✅ Асинхронний код не блокує UI
private async void LoadUsers_Click(object sender, RoutedEventArgs e)
{
    var users = await _context.Users.ToListAsync(); // Не блокує UI
    UsersListBox.ItemsSource = users;
}

Що відбувається:

  1. Користувач натискає кнопку
  2. Запит до бази даних виконується асинхронно
  3. UI Thread вільний (вікно реагує на клік, можна перемістити)
  4. Коли дані готові, UI оновлюється
  5. Хороша 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();
Важливо: У десктопних додатках завжди використовуйте async версії методів EF Core. Синхронні методи блокують UI Thread і роблять додаток "зависаючим".

Практичні завдання

Рівень 1: Generic Repository з In-Memory реалізацією

Мета: Зрозуміти структуру Repository Pattern без прив'язки до бази даних.

Завдання:

Створіть In-Memory репозиторій для TodoItem:

  1. Структура:
    • TodoItem модель (Id, Title, IsCompleted, CreatedAt)
    • IRepository<T> інтерфейс
    • InMemoryRepository<T> реалізація через List<T>
  2. Функціональність:
    • GetAll, GetById, Add, Update, Remove
    • Дані зберігаються у List<T> (не база даних)
    • Id генерується автоматично
  3. Тестування:
    • Створіть 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.

Завдання:

Створіть додаток для управління книгами:

  1. Структура:
    • Book модель (Id, Title, Author, ISBN, PublishedYear)
    • IRepository<T> та GenericRepository<T>
    • IBookRepository та BookRepository
    • AppDbContext з DbSet<Book>
  2. Додаткові методи у IBookRepository:
    • GetBooksByAuthorAsync(string author)
    • SearchBooksAsync(string searchTerm)
    • GetBooksPageAsync(int pageNumber, int pageSize)
    • IsbnExistsAsync(string isbn)
  3. ViewModel:
    • BookListViewModel з CRUD операціями
    • Пошук та фільтрація
    • Пагінація

Критерії успіху:

  • Repository Pattern реалізовано правильно
  • SQLite підключено
  • Всі CRUD операції працюють
  • Додаткові методи працюють

Рівень 3: Повний CRUD з UoW, Repository, Settings

Мета: Побудувати повноцінний додаток з чистою архітектурою.

Завдання:

Створіть Task Manager додаток:

  1. Структура даних:
    • User (Id, Name, Email)
    • TaskItem (Id, Title, Description, DueDate, IsCompleted, UserId)
    • Зв'язок один-до-багатьох
  2. Repository Pattern:
    • IRepository<T>, GenericRepository<T>
    • IUserRepository, UserRepository
    • ITaskRepository, TaskRepository
    • IUnitOfWork, UnitOfWork
  3. Settings:
    • Theme (Light/Dark)
    • DefaultView (List/Grid)
    • AutoSave (bool)
    • Збереження розміру вікна
  4. Функціональність:
    • 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-репозиторії, бізнес-логіка відокремлена від доступу до даних.

Ключові висновки:

🏗️ Ієрархія

IRepository → GenericRepository → IUserRepository → UserRepository. Базові операції у Generic, специфічні у конкретних репозиторіях.

🔄 Unit of Work

Координація кількох репозиторіїв у одній транзакції. Один CommitAsync() для всіх змін. BeginTransaction/Commit/Rollback для складних операцій.

🧪 Тестованість

Mock репозиторії для unit-тестів. ViewModel не залежить від бази даних. Легко тестувати бізнес-логіку.

⚡ Async/Await

Всі методи async для не блокування UI Thread. ToListAsync(), FindAsync(), SaveChangesAsync(). Хороша user experience.

⚙️ Settings

JSON файл для налаштувань. ISettingsService через DI. Збереження теми, розміру вікна, preferences.

📐 Best Practices

Не повертайте IQueryable. Специфічні методи замість загальних. Lazy initialization у UoW. Specification Pattern для складних запитів.

Що далі?

Наступна стаття — ViewModel Testing покаже, як тестувати ViewModels з mock-репозиторіями.


Словник термінів

Repository Pattern — патерн для абстракції доступу до даних, що відокремлює бізнес-логіку від деталей зберігання.Generic Repository — базовий репозиторій з CRUD операціями для будь-якої сутності (IRepository).Specific Repository — репозиторій для конкретної сутності з додатковими методами (IUserRepository).Unit of Work (UoW) — патерн для координації роботи кількох репозиторіїв у одній транзакції.Aggregate Root — головна сутність, через яку відбувається доступ до пов'язаних сутностей.Expression<Func<T, bool>> — вираз для фільтрації даних, що може бути перетворений у SQL запит.Lazy Initialization — створення об'єкта лише при першому доступі (??= оператор).Specification Pattern — патерн для інкапсуляції бізнес-правил у окремі класи.IQueryable — інтерфейс для відкладеного виконання запитів (не матеріалізовано).IEnumerable — інтерфейс для матеріалізованих колекцій (дані вже завантажено).Materialization — виконання запиту та завантаження даних у пам'ять (ToList(), ToArray()).Abstraction Leak — витік деталей реалізації через абстракцію (наприклад, повернення IQueryable).Mock Object — тестовий об'єкт, що імітує поведінку реального об'єкта.Dependency Injection (DI) — передача залежностей через конструктор замість створення через new.Scoped Lifetime — час життя об'єкта обмежений scope (область видимості).Transaction — атомарна операція (або всі зміни застосовуються, або жодна).Commit — застосування змін транзакції до бази даних.Rollback — скасування змін транзакції.

Додаткові ресурси

📖 Repository Pattern

Офіційна документація про Repository та Unit of Work.

🏗️ Clean Architecture

Стаття Uncle Bob про Clean Architecture.

🔍 Specification Pattern

Детальніше про Specification Pattern.

📚 Попередня стаття: SQLite & EF Core

Повернутися до SQLite та EF Core.

📚 Наступна стаття: ViewModel Testing

Дізнатися про тестування ViewModels.