.NET MAUI 10でSQLite+EF Coreオフラインファーストアプリを構築する

.NET MAUI 10でSQLiteとEF Coreを使ったオフラインファーストアプリの構築方法を解説。DbContext設計からCRUD実装、WALモード最適化、データ同期アーキテクチャまで、実践コード付きで網羅しています。

はじめに:なぜオフラインファーストが重要なのか

モバイルアプリ開発をしていると、ネットワーク接続って「あって当然」と思いがちですよね。でも現実はそう甘くありません。地下鉄での通勤中、飛行機の機内、山間部でのフィールドワーク——ユーザーがアプリを一番使いたいタイミングに限って、接続が途切れていたりします。

正直なところ、筆者自身も過去のプロジェクトで「ネットワークは常に使える前提」で設計して痛い目に遭った経験があります。実際のプロジェクトでは、不安定な環境でもアプリがシームレスに動くことへの期待は年々高まる一方です。

オフラインファーストとは、「オフライン状態をデフォルト」と捉える設計思想です。ローカルデータベースをプライマリストアとして使い、ネットワーク接続が回復したタイミングでバックグラウンドでサーバーと同期する。こうすることで、ユーザーはネットワーク状況を意識せずに、常にサクサク動くアプリ体験を得られるわけです。

本ガイドでは、2025年11月にリリースされた.NET 10(LTS、サポート期間:2028年11月まで)をベースとした.NET MAUI 10環境で、SQLiteとEntity Framework Coreを組み合わせたオフラインファーストアプリの構築方法を解説します。プロジェクトのセットアップからCRUD操作、WALモードによるパフォーマンス最適化、スキーママイグレーション、データ同期アーキテクチャまで、本番で即使える知識をまとめました。

SQLiteとEntity Framework Core:.NET MAUIでの選択肢

.NET MAUIでローカルデータベースを扱う場合、主に3つのアプローチがあります。それぞれ特徴が異なるので、プロジェクトの要件に合わせて選びましょう。

sqlite-net-pcl

軽量なSQLite ORMライブラリで、シンプルなテーブル操作に向いています。属性ベースのマッピングを提供していて、学習コストが低いのが魅力。キーバリューストアのような単純なデータ構造や、テーブル数が少ないプロトタイプ開発にはぴったりです。ただし、複雑なリレーションシップやLINQクエリのサポートは限定的です。

Microsoft.Data.Sqlite

ADO.NETプロバイダーとして生SQLを直接叩くアプローチ。最大限のパフォーマンスが必要な場合や、SQLiteの高度な機能(FTS5全文検索、JSON関数など)を直接使いたい場合に適しています。ただ、ボイラープレートコードが増えがちで、保守性の面ではちょっと辛いところがあります。

Entity Framework Core + SQLite(推奨)

本ガイドで採用するアプローチです。EF Coreは成熟したORMフレームワークで、LINQ、変更追跡、マイグレーション、リレーションシップマッピングなど、本番アプリに必要な機能をひと通り備えています。クエリ速度は生SQLiteと比較して約5〜8%程度のオーバーヘッドがありますが、開発生産性と保守性を考えれば、大半のプロジェクトではベストな選択です。

よくあるパターンとして、最初はsqlite-net-pclで始めたプロジェクトが、リレーションの複雑化やマイグレーション要件の増大とともにEF Coreへ移行する——というケースを何度も見てきました。最初からEF Coreを選んでおけば、この移行コストを丸ごとスキップできます。

必要なNuGetパッケージ

EF Core + SQLiteの構成で必要な主要パッケージは以下の通りです。

  • Microsoft.EntityFrameworkCore.Sqlite — EF CoreのSQLiteプロバイダー
  • SQLitePCLRaw.bundle_e_sqlite3 — ネイティブSQLiteバイナリのバンドル(各プラットフォーム対応)
  • Microsoft.EntityFrameworkCore.Design — マイグレーション生成用(開発時のみ)
  • CommunityToolkit.Mvvm — MVVMパターンのサポート(WeakReferenceMessenger含む)

プロジェクトセットアップとNuGetパッケージ

では、さっそく.NET MAUI 10プロジェクトを作成して、必要なパッケージを追加していきましょう。

dotnet new maui -n OfflineFirstApp -f net10.0
cd OfflineFirstApp
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package SQLitePCLRaw.bundle_e_sqlite3
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package CommunityToolkit.Mvvm

.csprojファイルのPackageReference部分はこんな感じになります。

<ItemGroup>
  <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
  <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
    <PrivateAssets>all</PrivateAssets>
    <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
  </PackageReference>
  <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
</ItemGroup>

推奨プロジェクト構造

保守性とテスタビリティを意識して、以下のようなレイヤードアーキテクチャをおすすめします。

OfflineFirstApp/
├── Entities/           # データベースエンティティ
│   ├── TodoItem.cs
│   ├── Category.cs
│   └── ChangeRecord.cs
├── Data/               # DbContext・データベース設定
│   └── AppDbContext.cs
├── Services/           # ビジネスロジック・データアクセス
│   ├── ITodoService.cs
│   ├── TodoService.cs
│   ├── ISyncService.cs
│   └── SyncService.cs
├── ViewModels/         # MVVM ViewModel
│   ├── TodoListViewModel.cs
│   └── TodoDetailViewModel.cs
├── Views/              # XAML UI
│   ├── TodoListPage.xaml
│   └── TodoDetailPage.xaml
├── MauiProgram.cs
└── App.xaml

この構造なら、EntitiesとDataレイヤーを後から共有ライブラリとして切り出すのも簡単です。マイグレーション用のコンソールプロジェクトとの共有も見据えた設計になっています。

DbContextの設計とDI登録

EF Coreの中心となるDbContextを定義していきます。モバイルアプリ特有の考慮点も含めた実装を見ていきましょう。

AppDbContextの実装

using Microsoft.EntityFrameworkCore;
using OfflineFirstApp.Entities;

namespace OfflineFirstApp.Data;

public class AppDbContext : DbContext
{
    public DbSet<TodoItem> TodoItems => Set<TodoItem>();
    public DbSet<Category> Categories => Set<Category>();
    public DbSet<ChangeRecord> ChangeRecords => Set<ChangeRecord>();

    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<TodoItem>(entity =>
        {
            entity.HasIndex(e => e.SyncStatus);
            entity.HasIndex(e => e.ModifiedAt);
            entity.HasOne(e => e.Category)
                  .WithMany(c => c.TodoItems)
                  .HasForeignKey(e => e.CategoryId)
                  .OnDelete(DeleteBehavior.SetNull);
        });

        modelBuilder.Entity<ChangeRecord>(entity =>
        {
            entity.HasIndex(e => e.IsSynced);
            entity.HasIndex(e => e.CreatedAt);
        });

        modelBuilder.Entity<Category>().HasData(
            new Category { Id = 1, Name = "仕事", ServerGuid = Guid.NewGuid() },
            new Category { Id = 2, Name = "プライベート", ServerGuid = Guid.NewGuid() },
            new Category { Id = 3, Name = "買い物", ServerGuid = Guid.NewGuid() }
        );
    }
}

MauiProgram.csでのDI登録

SQLitePCLの初期化とDbContextのDI登録を行います。ここで一つ大事な注意点——特にiOSではSQLitePCL.Batteries_V2.Init()の呼び出しが必須です。これを忘れると実行時にSQLiteへの接続でクラッシュします(筆者も最初ハマりました)。

using Microsoft.EntityFrameworkCore;
using OfflineFirstApp.Data;
using OfflineFirstApp.Services;
using OfflineFirstApp.ViewModels;
using OfflineFirstApp.Views;

namespace OfflineFirstApp;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        // iOS/macCatalystではSQLitePCLの初期化が必須
        SQLitePCL.Batteries_V2.Init();

        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        // データベースパスの構築
        var dbPath = Path.Combine(
            FileSystem.AppDataDirectory, "offlinefirst.db3");

        // DbContextのDI登録
        builder.Services.AddDbContext<AppDbContext>(options =>
        {
            options.UseSqlite($"Data Source={dbPath}");
#if DEBUG
            options.EnableSensitiveDataLogging();
            options.EnableDetailedErrors();
#endif
        });

        // サービス登録
        builder.Services.AddSingleton<ITodoService, TodoService>();
        builder.Services.AddSingleton<ISyncService, SyncService>();
        builder.Services.AddSingleton<IConnectivity>(
            Connectivity.Current);

        // ViewModel登録
        builder.Services.AddTransient<TodoListViewModel>();
        builder.Services.AddTransient<TodoDetailViewModel>();

        // Page登録
        builder.Services.AddTransient<TodoListPage>();
        builder.Services.AddTransient<TodoDetailPage>();

        var app = builder.Build();

        // データベース初期化(マイグレーション適用)
        using (var scope = app.Services.CreateScope())
        {
            var context = scope.ServiceProvider
                .GetRequiredService<AppDbContext>();
            context.Database.MigrateAsync()
                .GetAwaiter().GetResult();
        }

        return app;
    }
}

FileSystem.AppDataDirectoryは、各プラットフォームで適切なアプリデータディレクトリを返してくれるクロスプラットフォームAPIです。Androidでは内部ストレージ、iOSではLibrary/Data、Windowsでは%LOCALAPPDATA%配下のパスが自動的に解決されます。地味に便利ですよね。

エンティティ設計とCRUD操作の実装

エンティティの定義

ここからが本題。オフラインファースト設計を見据えたエンティティを定義します。ポイントは、ローカルIDとサーバーGUIDを分離する設計です。ローカルではauto-incrementのint IDを使いつつ、サーバー同期にはGUIDを使う——この二重ID設計が、後々の同期処理をぐっと楽にしてくれます。

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace OfflineFirstApp.Entities;

public enum SyncStatus
{
    Synced = 0,       // サーバーと同期済み
    PendingCreate = 1, // ローカルで新規作成、未同期
    PendingUpdate = 2, // ローカルで更新、未同期
    PendingDelete = 3  // ローカルで削除マーク、未同期
}

public interface ISyncable
{
    Guid ServerGuid { get; set; }
    SyncStatus SyncStatus { get; set; }
    DateTime ModifiedAt { get; set; }
}

public class TodoItem : ISyncable
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }

    public Guid ServerGuid { get; set; } = Guid.NewGuid();

    [Required]
    [MaxLength(200)]
    public string Title { get; set; } = string.Empty;

    [MaxLength(2000)]
    public string? Description { get; set; }

    public bool IsCompleted { get; set; }

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

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

    public SyncStatus SyncStatus { get; set; } = SyncStatus.PendingCreate;

    public int? CategoryId { get; set; }

    [ForeignKey(nameof(CategoryId))]
    public Category? Category { get; set; }
}

public class Category : ISyncable
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }

    public Guid ServerGuid { get; set; } = Guid.NewGuid();

    [Required]
    [MaxLength(100)]
    public string Name { get; set; } = string.Empty;

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

    public SyncStatus SyncStatus { get; set; } = SyncStatus.Synced;

    public ICollection<TodoItem> TodoItems { get; set; } = [];
}

public class ChangeRecord
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }

    [Required]
    public string EntityType { get; set; } = string.Empty;

    public Guid EntityGuid { get; set; }

    [Required]
    public string OperationType { get; set; } = string.Empty;

    public string? SerializedData { get; set; }

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

    public bool IsSynced { get; set; } = false;
}

サービスインターフェースと実装

モバイルアプリでは、UIスレッドをブロックしないことが絶対条件です。すべてのデータベース操作は非同期で実装しましょう。

namespace OfflineFirstApp.Services;

public interface ITodoService
{
    Task<List<TodoItem>> GetAllAsync(
        CancellationToken ct = default);
    Task<List<TodoItem>> GetByCategoryAsync(
        int categoryId, CancellationToken ct = default);
    Task<TodoItem?> GetByIdAsync(
        int id, CancellationToken ct = default);
    Task<TodoItem> CreateAsync(
        TodoItem item, CancellationToken ct = default);
    Task UpdateAsync(
        TodoItem item, CancellationToken ct = default);
    Task DeleteAsync(
        int id, CancellationToken ct = default);
    Task<List<TodoItem>> GetPendingSyncItemsAsync(
        CancellationToken ct = default);
}

続いて、実装クラスです。注目してほしいのは、各CRUD操作でChangeRecordを同時に記録している点。これが同期の要になります。

using Microsoft.EntityFrameworkCore;
using OfflineFirstApp.Data;
using OfflineFirstApp.Entities;
using System.Text.Json;

namespace OfflineFirstApp.Services;

public class TodoService : ITodoService
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public TodoService(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    public async Task<List<TodoItem>> GetAllAsync(
        CancellationToken ct = default)
    {
        await using var context = await _contextFactory
            .CreateDbContextAsync(ct);
        return await context.TodoItems
            .Include(t => t.Category)
            .Where(t => t.SyncStatus != SyncStatus.PendingDelete)
            .OrderByDescending(t => t.ModifiedAt)
            .ToListAsync(ct);
    }

    public async Task<List<TodoItem>> GetByCategoryAsync(
        int categoryId, CancellationToken ct = default)
    {
        await using var context = await _contextFactory
            .CreateDbContextAsync(ct);
        return await context.TodoItems
            .Include(t => t.Category)
            .Where(t => t.CategoryId == categoryId
                && t.SyncStatus != SyncStatus.PendingDelete)
            .OrderByDescending(t => t.ModifiedAt)
            .ToListAsync(ct);
    }

    public async Task<TodoItem?> GetByIdAsync(
        int id, CancellationToken ct = default)
    {
        await using var context = await _contextFactory
            .CreateDbContextAsync(ct);
        return await context.TodoItems
            .Include(t => t.Category)
            .FirstOrDefaultAsync(t => t.Id == id, ct);
    }

    public async Task<TodoItem> CreateAsync(
        TodoItem item, CancellationToken ct = default)
    {
        await using var context = await _contextFactory
            .CreateDbContextAsync(ct);

        item.CreatedAt = DateTime.UtcNow;
        item.ModifiedAt = DateTime.UtcNow;
        item.SyncStatus = SyncStatus.PendingCreate;

        context.TodoItems.Add(item);

        context.ChangeRecords.Add(new ChangeRecord
        {
            EntityType = nameof(TodoItem),
            EntityGuid = item.ServerGuid,
            OperationType = "Create",
            SerializedData = JsonSerializer.Serialize(item),
            CreatedAt = DateTime.UtcNow
        });

        await context.SaveChangesAsync(ct);
        return item;
    }

    public async Task UpdateAsync(
        TodoItem item, CancellationToken ct = default)
    {
        await using var context = await _contextFactory
            .CreateDbContextAsync(ct);

        var existing = await context.TodoItems
            .FirstOrDefaultAsync(t => t.Id == item.Id, ct)
            ?? throw new InvalidOperationException(
                $"TodoItem with Id {item.Id} not found.");

        existing.Title = item.Title;
        existing.Description = item.Description;
        existing.IsCompleted = item.IsCompleted;
        existing.CategoryId = item.CategoryId;
        existing.ModifiedAt = DateTime.UtcNow;

        if (existing.SyncStatus == SyncStatus.Synced)
        {
            existing.SyncStatus = SyncStatus.PendingUpdate;
        }

        context.ChangeRecords.Add(new ChangeRecord
        {
            EntityType = nameof(TodoItem),
            EntityGuid = existing.ServerGuid,
            OperationType = "Update",
            SerializedData = JsonSerializer.Serialize(existing),
            CreatedAt = DateTime.UtcNow
        });

        await context.SaveChangesAsync(ct);
    }

    public async Task DeleteAsync(
        int id, CancellationToken ct = default)
    {
        await using var context = await _contextFactory
            .CreateDbContextAsync(ct);

        var item = await context.TodoItems
            .FirstOrDefaultAsync(t => t.Id == id, ct);
        if (item is null) return;

        item.SyncStatus = SyncStatus.PendingDelete;
        item.ModifiedAt = DateTime.UtcNow;

        context.ChangeRecords.Add(new ChangeRecord
        {
            EntityType = nameof(TodoItem),
            EntityGuid = item.ServerGuid,
            OperationType = "Delete",
            CreatedAt = DateTime.UtcNow
        });

        await context.SaveChangesAsync(ct);
    }

    public async Task<List<TodoItem>> GetPendingSyncItemsAsync(
        CancellationToken ct = default)
    {
        await using var context = await _contextFactory
            .CreateDbContextAsync(ct);
        return await context.TodoItems
            .Where(t => t.SyncStatus != SyncStatus.Synced)
            .OrderBy(t => t.ModifiedAt)
            .ToListAsync(ct);
    }
}

ViewModelでの利用

.NET 10ではMessagingCenterがinternalになってしまったので、CommunityToolkit.MvvmのWeakReferenceMessengerを使います。また、ListViewTableViewは非推奨になり、CollectionViewが標準です。この辺りの変更、地味に影響範囲が大きいので要注意。

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using OfflineFirstApp.Entities;
using OfflineFirstApp.Services;
using System.Collections.ObjectModel;

namespace OfflineFirstApp.ViewModels;

public partial class TodoListViewModel : ObservableObject
{
    private readonly ITodoService _todoService;
    private readonly ISyncService _syncService;

    [ObservableProperty]
    private bool _isLoading;

    [ObservableProperty]
    private bool _isSyncing;

    [ObservableProperty]
    private string _syncStatusText = "同期済み";

    public ObservableCollection<TodoItem> TodoItems { get; } = [];

    public TodoListViewModel(
        ITodoService todoService,
        ISyncService syncService)
    {
        _todoService = todoService;
        _syncService = syncService;

        WeakReferenceMessenger.Default
            .Register<SyncCompletedMessage>(this, (r, m) =>
            {
                MainThread.BeginInvokeOnMainThread(async () =>
                    await LoadItemsAsync());
            });
    }

    [RelayCommand]
    private async Task LoadItemsAsync()
    {
        if (IsLoading) return;

        try
        {
            IsLoading = true;
            var items = await _todoService.GetAllAsync();

            TodoItems.Clear();
            foreach (var item in items)
            {
                TodoItems.Add(item);
            }

            var pendingCount = items
                .Count(i => i.SyncStatus != SyncStatus.Synced);
            SyncStatusText = pendingCount > 0
                ? $"未同期: {pendingCount}件"
                : "同期済み";
        }
        finally
        {
            IsLoading = false;
        }
    }

    [RelayCommand]
    private async Task SyncAsync()
    {
        if (IsSyncing) return;

        try
        {
            IsSyncing = true;
            SyncStatusText = "同期中...";
            await _syncService.SyncAllAsync();
            await LoadItemsAsync();
        }
        catch (Exception ex)
        {
            SyncStatusText = $"同期エラー: {ex.Message}";
        }
        finally
        {
            IsSyncing = false;
        }
    }
}

public class SyncCompletedMessage { }

WALモードとパフォーマンス最適化

モバイルデバイスでは、I/O性能がアプリの体感速度に直結します。SQLiteのWAL(Write-Ahead Logging)モードは、並行アクセスを伴うアプリでは事実上の標準になっています。

WALモードとは

通常のジャーナルモード(rollback journal)だと、書き込み操作でデータベースファイル全体がロックされます。WALモードでは変更を別のWALファイルに書き込むため、読み取りと書き込みを並行実行できるようになります。

つまり、UIスレッドでの読み取りがバックグラウンドの書き込みにブロックされなくなるわけです。これはモバイルアプリにとって非常に大きなメリットです。

最適化されたデータベース初期化

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using OfflineFirstApp.Data;

namespace OfflineFirstApp.Services;

public static class DatabaseInitializer
{
    public static async Task InitializeAsync(
        AppDbContext context)
    {
        await context.Database.MigrateAsync();

        // WALモードの有効化
        await context.Database.ExecuteSqlRawAsync(
            "PRAGMA journal_mode=WAL;");

        // 同期モードをNORMALに設定
        await context.Database.ExecuteSqlRawAsync(
            "PRAGMA synchronous=NORMAL;");

        // ページサイズの最適化(4KB)
        await context.Database.ExecuteSqlRawAsync(
            "PRAGMA page_size=4096;");

        // メモリマップI/Oの有効化(256MB)
        await context.Database.ExecuteSqlRawAsync(
            "PRAGMA mmap_size=268435456;");

        // キャッシュサイズの増加(約8MB)
        await context.Database.ExecuteSqlRawAsync(
            "PRAGMA cache_size=-8000;");

        // tempストアをメモリに設定
        await context.Database.ExecuteSqlRawAsync(
            "PRAGMA temp_store=MEMORY;");
    }
}

バッチ操作による一括挿入の高速化

大量のデータを挿入する場合、個別にSaveChangesAsyncを呼ぶのではなく、トランザクション内でバッチ処理を行うと劇的に速くなります。

public async Task BulkInsertAsync(
    List<TodoItem> items, CancellationToken ct = default)
{
    await using var context = await _contextFactory
        .CreateDbContextAsync(ct);

    await using var transaction = await context.Database
        .BeginTransactionAsync(ct);

    try
    {
        const int batchSize = 500;
        for (int i = 0; i < items.Count; i += batchSize)
        {
            var batch = items.Skip(i).Take(batchSize);
            context.TodoItems.AddRange(batch);
            await context.SaveChangesAsync(ct);
            context.ChangeTracker.Clear();
        }

        await transaction.CommitAsync(ct);
    }
    catch
    {
        await transaction.RollbackAsync(ct);
        throw;
    }
}

実測値として、1000件のレコード挿入がトランザクションなしで約3秒かかるところ、このバッチ方式だと約200ミリ秒に短縮されます。桁が違いますよね。初回同期時にサーバーから大量のデータをプルする場面で、この最適化は特に威力を発揮します。

スキーママイグレーション戦略

EF Coreのマイグレーションは強力ですが、.NET MAUIプロジェクトではCLIツールが直接サポートされていないという少し厄介な問題があります。ここでは、実用的なマイグレーション戦略を紹介します。

共有ライブラリ+コンソールマイグレータパターン

おすすめのアプローチは、エンティティとDbContextを共有ライブラリに切り出して、マイグレーション生成用のコンソールプロジェクトを別途用意する方法です。

OfflineFirstApp.sln
├── OfflineFirstApp/            # MAUIアプリ本体
├── OfflineFirstApp.Core/       # 共有ライブラリ(Entities, Data)
└── OfflineFirstApp.Migrator/   # コンソールアプリ(マイグレーション用)

マイグレーションの生成は、Migratorプロジェクトから実行します。

# マイグレーション生成
dotnet ef migrations add AddPriorityColumn \
  --project OfflineFirstApp.Core \
  --startup-project OfflineFirstApp.Migrator \
  --output-dir Migrations

# マイグレーションのSQL出力(レビュー用)
dotnet ef migrations script \
  --project OfflineFirstApp.Core \
  --startup-project OfflineFirstApp.Migrator

ランタイムマイグレーションの適用

アプリ起動時にマイグレーションを自動適用する方式は、開発中や社内アプリでは便利です。ただし、アプリストア配布のアプリではちょっと注意が必要。

// 起動時のマイグレーション適用
await context.Database.MigrateAsync();

// EnsureCreated は既存DBにマイグレーションが未適用の場合は使用不可
// 初回インストール時のみの利用に限る
// await context.Database.EnsureCreatedAsync();

バージョン管理によるマイグレーション戦略

アプリストアでリリースする場合、ユーザーがバージョンをスキップしてアップデートする可能性があります。たとえばv1.0からv1.2へ直接アップデートするようなケースです。ありがたいことに、EF CoreのMigrateAsync()は未適用のマイグレーションを順序通りに適用してくれるので、この問題は自動的に処理されます。

とはいえ、実際のプロジェクトでは以下のベストプラクティスを守ることをおすすめします。

  • マイグレーションには必ず意味のある名前を付ける(例:AddPriorityToTodoItem
  • データ破壊的な変更(カラム削除など)はマイグレーションを分割して、段階的にリリースする
  • リリース前に、クリーンインストールと各バージョンからのアップグレードを必ずテストする
  • マイグレーション失敗時のロールバック戦略を用意しておく(バックアップDB → リストア)

オフラインファーストアーキテクチャの設計

さて、ここからがこの記事の核心部分です。オフラインファーストアーキテクチャの設計思想は、「ローカルデータベースが常にSingle Source of Truth(唯一の真実の源泉)である」ということ。以下に全体のアーキテクチャ構造を示します。

┌─────────────────────────────────────────────┐
│                   UI Layer                   │
│          (CollectionView + MVVM)             │
├─────────────────────────────────────────────┤
│               ViewModel Layer                │
│         (ObservableObject + Commands)        │
├─────────────────────────────────────────────┤
│              Service Layer                   │
│    ┌───────────────┐  ┌──────────────────┐  │
│    │  TodoService   │  │   SyncService    │  │
│    │ (CRUD操作)     │  │ (データ同期)      │  │
│    └───────┬───────┘  └────────┬─────────┘  │
│            │                   │             │
├────────────┼───────────────────┼─────────────┤
│            ▼                   ▼             │
│  ┌─────────────────┐  ┌───────────────┐     │
│  │  SQLite (EF Core)│  │  REST API     │     │
│  │  ローカルDB       │  │  リモートサーバー │     │
│  └─────────────────┘  └───────────────┘     │
└─────────────────────────────────────────────┘

データフロー:
  読み取り: UI → ViewModel → Service → SQLite(常にローカル)
  書き込み: UI → ViewModel → Service → SQLite → ChangeRecord記録
  同期:     SyncService → ChangeRecord取得 → API送信 → 結果をSQLiteに反映

すべての読み書きはまずローカルのSQLiteに対して行われます。同期サービスはChangeRecordテーブルに蓄積された変更を監視して、ネットワーク接続が使えるタイミングでサーバーへ送信する、という流れです。

変更追跡のための設計

先ほど定義したISyncableインターフェースとSyncStatus列挙体、そしてChangeRecordエンティティが、このアーキテクチャの要になります。ChangeRecordは操作のキュー(同期キュー)として機能し、以下のように動作します。

  1. ユーザーがデータを作成・更新・削除する
  2. TodoServiceがSQLiteに変更を書き込むと同時に、ChangeRecordを追加
  3. SyncServiceが定期的にChangeRecordテーブルを確認し、未同期のレコードをサーバーに送信
  4. 送信成功後、ChangeRecordのIsSyncedをtrueに、対象エンティティのSyncStatusSyncedに更新
  5. 一定期間経過した同期済みChangeRecordを定期的にパージ

接続監視とデータ同期の実装

SyncServiceの実装

.NET MAUIのIConnectivity APIを使って、ネットワーク接続状態をリアルタイムに監視します。オンラインに復帰したら自動で同期を走らせる仕組みです。

using CommunityToolkit.Mvvm.Messaging;
using Microsoft.EntityFrameworkCore;
using OfflineFirstApp.Data;
using OfflineFirstApp.Entities;
using System.Text.Json;

namespace OfflineFirstApp.Services;

public interface ISyncService
{
    Task SyncAllAsync(CancellationToken ct = default);
    void StartBackgroundSync();
    void StopBackgroundSync();
}

public class SyncService : ISyncService, IDisposable
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;
    private readonly IConnectivity _connectivity;
    private readonly HttpClient _httpClient;
    private PeriodicTimer? _syncTimer;
    private CancellationTokenSource? _syncCts;

    public SyncService(
        IDbContextFactory<AppDbContext> contextFactory,
        IConnectivity connectivity)
    {
        _contextFactory = contextFactory;
        _connectivity = connectivity;
        _httpClient = new HttpClient
        {
            Timeout = TimeSpan.FromSeconds(30)
        };

        _connectivity.ConnectivityChanged +=
            OnConnectivityChanged;
    }

    private async void OnConnectivityChanged(
        object? sender, ConnectivityChangedEventArgs e)
    {
        if (e.NetworkAccess == NetworkAccess.Internet)
        {
            try
            {
                await SyncAllAsync();
            }
            catch (Exception ex)
            {
                System.Diagnostics.Debug.WriteLine(
                    $"Auto-sync failed: {ex.Message}");
            }
        }
    }

    public async Task SyncAllAsync(
        CancellationToken ct = default)
    {
        if (_connectivity.NetworkAccess != NetworkAccess.Internet)
        {
            throw new InvalidOperationException(
                "ネットワーク接続がありません。");
        }

        await PushLocalChangesAsync(ct);
        await PullRemoteChangesAsync(ct);

        WeakReferenceMessenger.Default
            .Send(new SyncCompletedMessage());
    }

    private async Task PushLocalChangesAsync(
        CancellationToken ct)
    {
        await using var context = await _contextFactory
            .CreateDbContextAsync(ct);

        var pendingRecords = await context.ChangeRecords
            .Where(r => !r.IsSynced)
            .OrderBy(r => r.CreatedAt)
            .ToListAsync(ct);

        foreach (var record in pendingRecords)
        {
            try
            {
                var endpoint = record.OperationType switch
                {
                    "Create" =>
                        $"/api/{record.EntityType.ToLower()}",
                    "Update" =>
                        $"/api/{record.EntityType.ToLower()}/{record.EntityGuid}",
                    "Delete" =>
                        $"/api/{record.EntityType.ToLower()}/{record.EntityGuid}",
                    _ => throw new InvalidOperationException()
                };

                HttpResponseMessage response =
                    record.OperationType switch
                {
                    "Create" => await _httpClient.PostAsync(
                        endpoint,
                        new StringContent(
                            record.SerializedData ?? "{}",
                            System.Text.Encoding.UTF8,
                            "application/json"),
                        ct),
                    "Update" => await _httpClient.PutAsync(
                        endpoint,
                        new StringContent(
                            record.SerializedData ?? "{}",
                            System.Text.Encoding.UTF8,
                            "application/json"),
                        ct),
                    "Delete" => await _httpClient.DeleteAsync(
                        endpoint, ct),
                    _ => throw new InvalidOperationException()
                };

                if (response.IsSuccessStatusCode)
                {
                    record.IsSynced = true;
                }
            }
            catch (HttpRequestException)
            {
                break;
            }
        }

        await context.SaveChangesAsync(ct);
    }

    private async Task PullRemoteChangesAsync(
        CancellationToken ct)
    {
        await using var context = await _contextFactory
            .CreateDbContextAsync(ct);

        var lastSynced = await context.TodoItems
            .Where(t => t.SyncStatus == SyncStatus.Synced)
            .OrderByDescending(t => t.ModifiedAt)
            .FirstOrDefaultAsync(ct);

        var since = lastSynced?.ModifiedAt
            ?? DateTime.MinValue;

        var response = await _httpClient.GetAsync(
            $"/api/todoitem?modifiedSince={since:O}", ct);

        if (!response.IsSuccessStatusCode) return;

        var json = await response.Content
            .ReadAsStringAsync(ct);
        var remoteItems = JsonSerializer
            .Deserialize<List<TodoItemDto>>(json,
                new JsonSerializerOptions
                {
                    PropertyNameCaseInsensitive = true
                }) ?? [];

        foreach (var remote in remoteItems)
        {
            var local = await context.TodoItems
                .FirstOrDefaultAsync(
                    t => t.ServerGuid == remote.ServerGuid, ct);

            if (local is null)
            {
                context.TodoItems.Add(new TodoItem
                {
                    ServerGuid = remote.ServerGuid,
                    Title = remote.Title,
                    Description = remote.Description,
                    IsCompleted = remote.IsCompleted,
                    CreatedAt = remote.CreatedAt,
                    ModifiedAt = remote.ModifiedAt,
                    SyncStatus = SyncStatus.Synced
                });
            }
            else if (local.SyncStatus == SyncStatus.Synced)
            {
                local.Title = remote.Title;
                local.Description = remote.Description;
                local.IsCompleted = remote.IsCompleted;
                local.ModifiedAt = remote.ModifiedAt;
            }
            else
            {
                // Last-Write-Winsで競合解決
                if (remote.ModifiedAt > local.ModifiedAt)
                {
                    local.Title = remote.Title;
                    local.Description = remote.Description;
                    local.IsCompleted = remote.IsCompleted;
                    local.ModifiedAt = remote.ModifiedAt;
                    local.SyncStatus = SyncStatus.Synced;
                }
            }
        }

        await context.SaveChangesAsync(ct);
    }

    public void StartBackgroundSync()
    {
        _syncCts = new CancellationTokenSource();
        _syncTimer = new PeriodicTimer(
            TimeSpan.FromMinutes(5));

        Task.Run(async () =>
        {
            while (await _syncTimer.WaitForNextTickAsync(
                _syncCts.Token))
            {
                if (_connectivity.NetworkAccess
                    == NetworkAccess.Internet)
                {
                    try
                    {
                        await SyncAllAsync(_syncCts.Token);
                    }
                    catch (Exception ex)
                    {
                        System.Diagnostics.Debug.WriteLine(
                            $"Background sync: {ex.Message}");
                    }
                }
            }
        }, _syncCts.Token);
    }

    public void StopBackgroundSync()
    {
        _syncCts?.Cancel();
        _syncTimer?.Dispose();
    }

    public void Dispose()
    {
        StopBackgroundSync();
        _connectivity.ConnectivityChanged -=
            OnConnectivityChanged;
        _httpClient.Dispose();
        _syncCts?.Dispose();
    }
}

public record TodoItemDto(
    Guid ServerGuid,
    string Title,
    string? Description,
    bool IsCompleted,
    DateTime CreatedAt,
    DateTime ModifiedAt);

競合解決戦略の選択

上の実装ではLast-Write-Wins(最後の書き込みが勝つ)を採用しました。シンプルで多くのケースに対応できますが、アプリの性質によっては別の戦略も検討してみてください。

  • Server-Wins(サーバー優先):常にサーバーの値を採用。管理系アプリで管理者の変更が優先される場合に向いています。
  • Client-Wins(クライアント優先):常にローカルの値を採用。個人用アプリでユーザーの意図を尊重したい場合に。
  • フィールドレベルマージ:各フィールドごとに新しい値を採用します。実装は複雑になりますが、データ損失を最小限に抑えられます。
  • 手動解決:ユーザーにUIで競合内容を見せて選んでもらう方式。コラボレーションツールなどではこれが必要になることもあります。

プラットフォーム固有の注意点

.NET MAUI 10はクロスプラットフォームフレームワークですが、SQLiteの利用にあたってはプラットフォームごとに気をつけるべきポイントがあります。ここは地味ですが重要な部分なので、一つずつ確認していきましょう。

Android

  • データベースファイルはデフォルトで内部ストレージ(/data/data/{package}/files)に保存されます。外部ストレージへの保存は暗号化の観点から避けた方が無難です。
  • 大規模データベース(100MB超)の場合は、PRAGMA journal_size_limitを設定してWALファイルの肥大化を防いでください。
  • Android 14以降では、アプリのバックアップからデータベースファイルを除外することも検討してください。

iOS / macOS(Mac Catalyst)

  • SQLitePCL.Batteries_V2.Init()の呼び出しは絶対に忘れないでください。忘れるとランタイムでクラッシュします。MauiProgram.csの最初に書いておくのが鉄則です。
  • iOSではLibrary/Dataディレクトリに保存されます。iTunesバックアップに含まれるので、機密データには追加の暗号化を検討してください。
  • macOS(Mac Catalyst)では~/Library/Containers/{BundleId}/Data/Library配下にサンドボックスされます。

Windows

  • データベースは%LOCALAPPDATA%\Packages\{PackageFamilyName}\LocalStateに保存されます。
  • パスの取得は常にFileSystem.AppDataDirectory経由で。ハードコーディングは厳禁です。

.NET 10固有の変更点

  • CollectionViewがデフォルトListViewTableViewは.NET 10で非推奨になりました。リスト表示はCollectionViewを使いましょう。
  • MessagingCenterはinternal化:代わりにCommunityToolkit.MvvmのWeakReferenceMessengerを使ってください。
  • Handlerベースのアーキテクチャがさらに安定し、カスタムレンダラーからの移行も完了しています。

よくある質問(FAQ)

Q1: sqlite-net-pclとEF Coreどちらを使うべきですか?

プロジェクトの規模で判断するのがシンプルです。テーブルが3つ以下でリレーションシップが不要な簡易アプリならsqlite-net-pclで十分。それ以上の規模や、将来的な拡張が見込まれるならEF Coreがおすすめです。約5〜8%のオーバーヘッドはありますが、型安全なLINQ、変更追跡、マイグレーション機能が手に入ると考えれば、ほとんどの場合このトレードオフは受け入れられるはずです。

Q2: オフラインデータの競合はどう解決すればいいですか?

一番手軽なのはLast-Write-Wins(最後の書き込みが勝つ)です。実装がシンプルで、多くのユースケースに対応できます。ただし、複数ユーザーが同じデータを編集するコラボレーション系アプリでは、フィールドレベルのマージや手動解決が必要になることも。大事なのは、競合が発生してもデータが「静かに消える」ことがないよう、ログや通知の仕組みを設けることです。

Q3: WALモードのデメリットはありますか?

いくつか注意点はあります。WALファイル(-wal)とSHMファイル(-shm)がデータベースファイルに加えて生成されるため、バックアップ時にはこの3ファイルをセットで扱う必要があります。また、WALファイルが大きくなりすぎると読み取り性能が落ちるので、定期的なチェックポイント実行が推奨されます。とはいえ、モバイルアプリのローカルストレージ規模なら問題になることはほぼなく、WALモードを選ばない理由は特にありません。

Q4: EF Coreのマイグレーションはアプリストア配布でどう扱いますか?

アプリ起動時にDatabase.MigrateAsync()を呼ぶのが最もシンプルです。EF Coreは__EFMigrationsHistoryテーブルで適用済みマイグレーションを管理しているので、ユーザーがバージョンをスキップしてアップデートしても安全に動作します。ただ、念のためマイグレーション前にデータベースファイルのバックアップを取っておくことを強くおすすめします。万が一のときの保険ですね。

Q5: .NET MAUI 10でSQLiteのパフォーマンスはどのくらいですか?

SQLite 3.51.2(2026年1月時点の安定版)と.NET 10の組み合わせだと、一般的なモバイルデバイスで以下のようなパフォーマンスが期待できます。単純なCRUD操作(1レコード)は1ミリ秒未満。WALモード有効時の100件読み取りが約5〜10ミリ秒。EF Core経由の1000件バッチ挿入(トランザクション使用)で約200ミリ秒程度です。実は、パフォーマンスのボトルネックになるのは大抵SQLiteそのものではなく、UIスレッドでの同期的なDB操作です。すべての操作を非同期で行うことが、最も効果的な最適化と言えます。

まとめ

本ガイドでは、.NET MAUI 10とEF Core + SQLiteを使ったオフラインファーストアプリの構築方法を、設計から実装まで一通り解説しました。最後にポイントを振り返っておきましょう。

  • EF Core + SQLiteは保守性とパフォーマンスのバランスに優れた選択肢で、約5〜8%のオーバーヘッドと引き換えにLINQ、変更追跡、マイグレーションの恩恵を受けられる
  • WALモードの有効化と適切なPRAGMA設定で、モバイルデバイスでのSQLiteパフォーマンスを最大化できる
  • ISyncableインターフェースとChangeRecordパターンにより、オフラインでの変更を追跡してオンライン復帰時に確実に同期できる
  • IConnectivity APIを活用したバックグラウンド同期で、ユーザーはネットワーク状況を意識せずにアプリを使える
  • .NET 10固有の変更(CollectionViewのデフォルト化、MessagingCenterのinternal化)への対応も忘れずに

オフラインファーストは単なる技術パターンではなく、ユーザー体験に直結する設計思想です。ネットワーク接続が不確実なモバイル環境で、データの安全性とアプリの快適さを両立させるために、ぜひ本ガイドの内容をプロジェクトに取り入れてみてください。

著者について Editorial Team

Our team of expert writers and editors.