.NET MAUI 10 та SQLite: від CRUD до Repository Pattern

Покроковий посібник з інтеграції SQLite у .NET MAUI 10: налаштування, моделі даних, CRUD-операції, Repository Pattern, DI, оптимізація WAL та шифрування. Із робочим кодом для вашого проєкту.

SQLite у .NET MAUI 10: Repository Гайд 2026
On this page
  1. Вступ: чому саме SQLite для мобільних додатків
  2. Налаштування SQLite у проєкті .NET MAUI 10
  3. Створення моделі даних
  4. Сервіс для роботи з базою даних
  5. CRUD-операції: повний приклад
  6. Repository Pattern для масштабування
  7. Інтеграція з Dependency Injection
  8. Оптимізація продуктивності SQLite
  9. Шифрування бази даних
  10. SQLite.NET чи EF Core: що обрати для .NET MAUI
  11. Поширені помилки та як їх уникнути
  12. Часті запитання (FAQ)

Вступ: чому саме SQLite для мобільних додатків

Практично кожен мобільний додаток рано чи пізно потребує локального зберігання даних. Налаштування користувача, кешовані відповіді API, офлайн-режим — усе це вимагає надійної бази даних прямо на пристрої.

І тут SQLite — безумовний лідер. Він не потребує окремого серверного процесу, працює на всіх платформах і чудово інтегрується з .NET MAUI 10. Чесно кажучи, складно уявити мобільну розробку без нього.

Але є одна проблема. Більшість розробників зупиняються на базових прикладах із документації: один клас, один файл, мінімум структури. Спочатку все ніби працює. А потім додаток зростає до десятків таблиць і сотень запитів — і цей «простий» підхід перетворюється на технічний борг, який боляче рефакторити.

У цьому посібнику ми пройдемо повний шлях — від встановлення NuGet-пакетів до побудови масштабованого Repository Pattern із Dependency Injection, оптимізації продуктивності та шифрування даних. Кожен крок супроводжується робочим кодом для .NET MAUI 10, готовим до копіювання у ваш проєкт.

Налаштування SQLite у проєкті .NET MAUI 10

Встановлення NuGet-пакетів

Для роботи з SQLite у .NET MAUI потрібен пакет sqlite-net-pcl. Не звертайте уваги на «pcl» у назві — це актуальний і активно підтримуваний пакет:

dotnet add package sqlite-net-pcl
dotnet add package SQLitePCLRaw.bundle_green

Пакет SQLitePCLRaw.bundle_green забезпечує нативну реалізацію SQLite на кожній платформі — Android, iOS, macOS та Windows. Без нього ви отримаєте runtime-помилку при спробі створити з'єднання. Повірте, зловити цю помилку на етапі тестування краще, ніж у продакшені.

Визначення шляху до бази даних

SQLite зберігає всі дані у єдиному файлі. На мобільних платформах цей файл живе у приватній пісочниці (sandbox) додатка, недоступній для інших програм:

public static class DbConstants
{
    public const string DatabaseFilename = "app_data.db3";

    public static string DatabasePath =>
        Path.Combine(FileSystem.AppDataDirectory, DatabaseFilename);
}

FileSystem.AppDataDirectory — це API .NET MAUI, що повертає платформо-специфічний шлях. На Android це /data/data/{package}/files, на iOS — каталог Library пісочниці додатка. Приємна частина — вам взагалі не потрібно думати про різницю між платформами, MAUI абстрагує все автоматично.

Створення моделі даних

SQLite.NET використовує атрибути для маппінгу C#-класів на таблиці бази даних. Кожна публічна властивість стає стовпцем таблиці (якщо її не позначено атрибутом [Ignore]):

using SQLite;

[Table("contacts")]
public class Contact
{
    [PrimaryKey, AutoIncrement]
    public int Id { get; set; }

    [MaxLength(100), NotNull]
    public string FirstName { get; set; } = string.Empty;

    [MaxLength(100), NotNull]
    public string LastName { get; set; } = string.Empty;

    [MaxLength(200), Unique, Indexed]
    public string Email { get; set; } = string.Empty;

    [MaxLength(20)]
    public string? Phone { get; set; }

    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    public bool IsFavorite { get; set; }

    [Ignore]
    public string FullName => $"{FirstName} {LastName}";
}

Давайте розберемо ключові атрибути, які тут використовуються:

  • [PrimaryKey, AutoIncrement] — автоматично генерує унікальний цілочисельний ідентифікатор для кожного запису
  • [MaxLength(n)] — обмежує довжину рядкового поля до n символів
  • [NotNull] — забороняє NULL-значення на рівні бази даних
  • [Unique] — гарантує унікальність значення в межах таблиці
  • [Indexed] — створює індекс для прискорення пошуку за цим полем (про це ще поговоримо в розділі оптимізації)
  • [Ignore] — виключає властивість із маппінгу на стовпець бази
  • [Table("name")] — явно задає назву таблиці замість автоматичної генерації з імені класу

Сервіс для роботи з базою даних

Ось центральний компонент нашої архітектури — сервіс, що управляє підключенням до бази. Тут ми використовуємо ледачу асинхронну ініціалізацію, щоб база створювалась лише при першому зверненні:

using SQLite;

public class DatabaseService
{
    private SQLiteAsyncConnection? _database;

    private async Task<SQLiteAsyncConnection> GetConnectionAsync()
    {
        if (_database is not null)
            return _database;

        _database = new SQLiteAsyncConnection(
            DbConstants.DatabasePath,
            SQLiteOpenFlags.ReadWrite |
            SQLiteOpenFlags.Create |
            SQLiteOpenFlags.SharedCache);

        // Увімкнення WAL для кращої продуктивності
        await _database.EnableWriteAheadLoggingAsync();

        // Створення таблиць
        await _database.CreateTableAsync<Contact>();

        return _database;
    }
}

Прапорці SQLiteOpenFlags визначають поведінку з'єднання. ReadWrite дозволяє читання та запис, Create автоматично створить файл бази, якщо його ще не існує, а SharedCache дозволяє кільком підключенням спільно використовувати кеш. Такий набір прапорців підходить для більшості сценаріїв.

CRUD-операції: повний приклад

Тепер додамо до DatabaseService методи для повного циклу роботи з даними. Нічого надприродного — створення, читання, оновлення та видалення.

Створення (Create)

public async Task<int> AddContactAsync(Contact contact)
{
    var db = await GetConnectionAsync();
    return await db.InsertAsync(contact);
}

public async Task<int> AddContactsBatchAsync(IEnumerable<Contact> contacts)
{
    var db = await GetConnectionAsync();
    return await db.InsertAllAsync(contacts);
}

Метод InsertAsync повертає кількість вставлених рядків (зазвичай 1). Зручна деталь: після вставки властивість Id об'єкта автоматично заповнюється згенерованим значенням, тож вам не потрібно робити окремий запит, щоб його отримати.

Читання (Read)

public async Task<List<Contact>> GetAllContactsAsync()
{
    var db = await GetConnectionAsync();
    return await db.Table<Contact>()
                   .OrderBy(c => c.LastName)
                   .ToListAsync();
}

public async Task<Contact?> GetContactByIdAsync(int id)
{
    var db = await GetConnectionAsync();
    return await db.Table<Contact>()
                   .FirstOrDefaultAsync(c => c.Id == id);
}

public async Task<List<Contact>> SearchContactsAsync(string query)
{
    var db = await GetConnectionAsync();
    return await db.Table<Contact>()
                   .Where(c => c.FirstName.Contains(query) ||
                               c.LastName.Contains(query) ||
                               c.Email.Contains(query))
                   .ToListAsync();
}

public async Task<List<Contact>> GetFavoritesAsync()
{
    var db = await GetConnectionAsync();
    return await db.Table<Contact>()
                   .Where(c => c.IsFavorite)
                   .OrderBy(c => c.FirstName)
                   .ToListAsync();
}

Оновлення (Update)

public async Task<int> UpdateContactAsync(Contact contact)
{
    var db = await GetConnectionAsync();
    return await db.UpdateAsync(contact);
}

UpdateAsync знаходить запис за Primary Key і оновлює всі інші поля. Один нюанс, який варто тримати в голові: якщо запису з таким Id не існує, метод мовчки повертає 0 — жодного виключення не буде.

Видалення (Delete)

public async Task<int> DeleteContactAsync(Contact contact)
{
    var db = await GetConnectionAsync();
    return await db.DeleteAsync(contact);
}

public async Task<int> DeleteAllContactsAsync()
{
    var db = await GetConnectionAsync();
    return await db.DeleteAllAsync<Contact>();
}

Repository Pattern для масштабування

Окей, CRUD працює. Але що робити, коли у додатку з'являються десятки моделей? Дублювати ті самі методи для кожної — прямий шлях до хаосу. Узагальнений (generic) Repository Pattern вирішує цю проблему елегантно.

Інтерфейс репозиторію

public interface IRepository<T> where T : class, new()
{
    Task<List<T>> GetAllAsync();
    Task<T?> GetByIdAsync(int id);
    Task<int> AddAsync(T entity);
    Task<int> AddRangeAsync(IEnumerable<T> entities);
    Task<int> UpdateAsync(T entity);
    Task<int> DeleteAsync(T entity);
    Task<int> CountAsync();
}

Реалізація на базі SQLite

public class SqliteRepository<T> : IRepository<T> where T : class, new()
{
    private readonly SQLiteAsyncConnection _db;

    public SqliteRepository(SQLiteAsyncConnection db)
    {
        _db = db;
    }

    public Task<List<T>> GetAllAsync()
        => _db.Table<T>().ToListAsync();

    public Task<T?> GetByIdAsync(int id)
        => _db.FindAsync<T>(id);

    public Task<int> AddAsync(T entity)
        => _db.InsertAsync(entity);

    public Task<int> AddRangeAsync(IEnumerable<T> entities)
        => _db.InsertAllAsync(entities);

    public Task<int> UpdateAsync(T entity)
        => _db.UpdateAsync(entity);

    public Task<int> DeleteAsync(T entity)
        => _db.DeleteAsync(entity);

    public Task<int> CountAsync()
        => _db.Table<T>().CountAsync();
}

Тепер для кожної нової моделі — будь то Order, Product чи Setting — вам потрібно лише зареєструвати новий IRepository<T> у DI-контейнері. Жодного додаткового коду для базових CRUD-операцій. На мій досвід, це одне з найкращих архітектурних рішень, яке можна прийняти на ранній стадії проєкту.

Інтеграція з Dependency Injection

Реєстрація сервісів у MauiProgram.cs — ключовий крок. Без нього весь наш Repository Pattern залишається красивою теорією:

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder.UseMauiApp<App>();

        // Реєстрація з'єднання з БД як Singleton
        builder.Services.AddSingleton<SQLiteAsyncConnection>(provider =>
        {
            var connection = new SQLiteAsyncConnection(
                DbConstants.DatabasePath,
                SQLiteOpenFlags.ReadWrite |
                SQLiteOpenFlags.Create |
                SQLiteOpenFlags.SharedCache);

            connection.EnableWriteAheadLoggingAsync().Wait();
            connection.CreateTableAsync<Contact>().Wait();

            return connection;
        });

        // Реєстрація репозиторіїв
        builder.Services.AddSingleton<IRepository<Contact>,
            SqliteRepository<Contact>>();

        // Реєстрація ViewModel та сторінок
        builder.Services.AddTransient<ContactsViewModel>();
        builder.Services.AddTransient<ContactsPage>();

        return builder.Build();
    }
}

Зверніть увагу: з'єднання з базою реєструється як Singleton, тому що SQLiteAsyncConnection внутрішньо управляє пулом підключень і безпечний для багатопотокового використання. А ViewModel та сторінки — як Transient, щоб кожен перехід створював свіжий екземпляр. Це важливий момент, який часто плутають.

Використання у ViewModel

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;

public partial class ContactsViewModel : ObservableObject
{
    private readonly IRepository<Contact> _repository;

    public ContactsViewModel(IRepository<Contact> repository)
    {
        _repository = repository;
    }

    [ObservableProperty]
    private ObservableCollection<Contact> _contacts = new();

    [ObservableProperty]
    private bool _isLoading;

    [RelayCommand]
    private async Task LoadContactsAsync()
    {
        IsLoading = true;
        try
        {
            var items = await _repository.GetAllAsync();
            Contacts = new ObservableCollection<Contact>(items);
        }
        finally
        {
            IsLoading = false;
        }
    }

    [RelayCommand]
    private async Task DeleteContactAsync(Contact contact)
    {
        await _repository.DeleteAsync(contact);
        Contacts.Remove(contact);
    }
}

Завдяки інтерфейсу IRepository<T> цю ViewModel легко покрити модульними тестами — достатньо підмінити реальний репозиторій на mock-об'єкт. І це не теоретична перевага — коли ваш додаток виросте, ви будете вдячні собі за таку архітектуру.

Оптимізація продуктивності SQLite

Write-Ahead Logging (WAL)

WAL — це режим журналювання SQLite, який дозволяє одночасне читання та запис без блокувань. Як це працює? Замість блокування всієї бази при записі, зміни спочатку потрапляють у окремий WAL-файл, а читачі продовжують працювати з основним файлом.

Ми вже увімкнули WAL у нашому сервісі вище — і це одна з найважливіших оптимізацій. Особливо відчутна різниця, коли UI-потік читає дані паралельно із фоновим записом.

Пакетні операції через транзакції

Для масового вставлення даних використовуйте RunInTransactionAsync замість багаторазового виклику InsertAsync у циклі:

public async Task ImportContactsAsync(List<Contact> contacts)
{
    var db = await GetConnectionAsync();
    await db.RunInTransactionAsync(conn =>
    {
        foreach (var contact in contacts)
        {
            conn.Insert(contact);
        }
    });
}

Різниця в продуктивності тут просто вражає. Транзакція об'єднує всі операції в один дисковий запис. Вставка 1000 записів без транзакції може зайняти 10–15 секунд, а в транзакції — менше секунди. Це не перебільшення, а реальні цифри.

Пагінація для великих таблиць

Коли таблиця містить тисячі записів, завантажувати все в пам'ять — погана ідея. Використовуйте пагінацію:

public async Task<List<Contact>> GetContactsPagedAsync(
    int page, int pageSize = 20)
{
    var db = await GetConnectionAsync();
    return await db.Table<Contact>()
                   .OrderBy(c => c.LastName)
                   .Skip(page * pageSize)
                   .Take(pageSize)
                   .ToListAsync();
}

Шифрування бази даних

Якщо ваш додаток зберігає персональні або фінансові дані, шифрування — не опція, а необхідність. SQLite можна зашифрувати за допомогою SQLCipher. Спочатку замініть стандартний пакет:

dotnet remove package sqlite-net-pcl
dotnet add package sqlite-net-sqlcipher

Після заміни пакета створіть підключення із ключем шифрування:

var options = new SQLiteConnectionString(
    DbConstants.DatabasePath,
    storeDateTimeAsTicks: true,
    key: encryptionKey);

var db = new SQLiteAsyncConnection(options);

А ключ шифрування зберігайте у безпечному сховищі платформи через SecureStorage API:

// Збереження ключа при першому запуску
var key = await SecureStorage.Default.GetAsync("db_key");
if (key is null)
{
    key = Guid.NewGuid().ToString();
    await SecureStorage.Default.SetAsync("db_key", key);
}

SecureStorage використовує Keychain на iOS та Android KeyStore — це найбезпечніший спосіб зберігання секретів на мобільних платформах. І головне правило: ніколи (взагалі ніколи!) не записуйте ключ шифрування безпосередньо у вихідний код.

SQLite.NET чи EF Core: що обрати для .NET MAUI

У .NET MAUI 10 є два основних шляхи роботи з SQLite. Вибір залежить від складності вашого додатка:

  • sqlite-net-pcl — легковагий ORM з мінімальним впливом на розмір додатка. Ідеальний для проєктів із простою структурою даних (до 10–15 таблиць без складних зв'язків). Працює швидше завдяки простоті.
  • EF Core + Microsoft.EntityFrameworkCore.Sqlite — повнофункціональний ORM з автоматичними міграціями, складними LINQ-запитами та підтримкою lazy/eager loading. Варто обирати, коли у вас складна реляційна модель або ви перевикористовуєте код із веб-проєкту на ASP.NET Core.

На мою думку, для більшості мобільних додатків sqlite-net-pcl — оптимальний вибір. Він додає мінімум до розміру APK/IPA, а його API інтуїтивно зрозумілий навіть для тих, хто тільки починає працювати з .NET MAUI.

Поширені помилки та як їх уникнути

За роки роботи з SQLite у мобільних проєктах я бачив одні й ті самі помилки знову і знову. Ось найтиповіші:

  • Синхронне підключення у UI-потоці — використання SQLiteConnection замість SQLiteAsyncConnection заблокує головний потік і спричинить «зависання» інтерфейсу. Завжди використовуйте асинхронну версію.
  • Множинні екземпляри з'єднання — створення нового SQLiteAsyncConnection у кожному методі призводить до витоків пам'яті. Реєструйте з'єднання як Singleton через DI.
  • Вставка у циклі без транзакції — кожен окремий InsertAsync виконує дисковий запис. Для масових операцій завжди обгортайте вставки у RunInTransactionAsync.
  • Ігнорування індексів — без атрибута [Indexed] на полях, що часто використовуються у Where-запитах, пошук по великій таблиці буде болісно повільним.
  • Зберігання DateTime як рядка — за замовчуванням SQLite.NET зберігає DateTime як ticks (числове значення), що забезпечує коректне сортування. Не змінюйте цю поведінку без дуже вагомої причини.

Часті запитання (FAQ)

Де зберігається файл бази даних SQLite у .NET MAUI?

Файл бази зберігається у приватному каталозі додатка, доступному через FileSystem.AppDataDirectory. На Android це /data/data/{package_name}/files/, на iOS — каталог Library пісочниці додатка. Інші програми не мають прямого доступу до цього файлу. При видаленні додатка файл бази також видаляється.

Чи можна використовувати SQLite для синхронізації з хмарним сервером?

Так, і це досить популярний підхід. SQLite ідеально працює як локальний кеш для офлайн-синхронізації. Серед популярних рішень — Azure Mobile Apps із IOfflineTable<T>, бібліотека NubeSync (open-source) або власний REST API з логікою конфліктного злиття. Принцип простий: SQLite зберігає дані локально та відстежує зміни, а синхронізація відбувається при відновленні мережі.

Як захистити дані у SQLite від несанкціонованого доступу?

Встановіть пакет sqlite-net-sqlcipher і створіть підключення із ключем шифрування. Ключ зберігайте через SecureStorage API .NET MAUI, який використовує Keychain на iOS та Android KeyStore. Файл бази стає повністю нечитабельним без правильного ключа — навіть при фізичному доступі до пристрою.

Як уникнути блокування UI при роботі з SQLite?

Завжди використовуйте SQLiteAsyncConnection замість синхронного SQLiteConnection. Усі операції з базою виконуються у фоновому потоці через async/await, залишаючи UI-потік вільним. Додатково увімкніть WAL-режим — він дозволяє одночасне читання та запис без взаємного блокування.

Скільки даних можна зберігати у SQLite на мобільному пристрої?

Технічно SQLite підтримує бази до 281 ТБ, але на практиці обмеження визначається вільним місцем на пристрої. Для мобільних додатків рекомендується тримати розмір бази в межах 50–100 МБ для оптимальної продуктивності. Якщо дані значно перевищують цей обсяг, розгляньте пагінацію, архівування старих записів або зберігання великих об'єктів (зображення, файли) окремо від бази.