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

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

.NET MAUI 10 SQLite: CRUD + Repository 2026

Вступ: чому саме 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 МБ для оптимальної продуктивності. Якщо дані значно перевищують цей обсяг, розгляньте пагінацію, архівування старих записів або зберігання великих об'єктів (зображення, файли) окремо від бази.

Про Автора Editorial Team

Our team of expert writers and editors.