Pendahuluan: Data Adalah Jantung Setiap Aplikasi Mobile
Bayangkan kamu membangun aplikasi e-commerce yang keren. Arsitekturnya sudah solid dengan MVVM dan Dependency Injection. Performanya sudah dioptimasi habis-habisan — dari startup time sampai memory management. Tapi begitu user membuka aplikasi di dalam lift atau di area tanpa sinyal... blank. Tidak ada data yang ditampilkan.
Semua effort arsitektur dan performa jadi percuma kalau aplikasi nggak bisa mengelola data dengan benar, terutama saat offline. Ini pernah terjadi di salah satu proyek yang pernah saya kerjakan, dan percayalah, feedback dari user cukup "menyakitkan".
Kalau kamu sudah mengikuti seri panduan kami sebelumnya tentang arsitektur .NET MAUI (MVVM, DI, Shell Navigation) dan optimasi performa, berarti kamu sudah punya fondasi yang kokoh. Sekarang saatnya melengkapi puzzle terakhir: bagaimana mengelola data lokal dengan efisien dan membangun aplikasi yang tetap berfungsi meskipun tanpa koneksi internet.
.NET MAUI menyediakan beberapa mekanisme penyimpanan data — dari yang paling sederhana seperti Preferences, sampai yang paling powerful seperti SQLite dengan Entity Framework Core. Di .NET 10, dukungan EF Core semakin matang dengan fitur-fitur baru seperti XAML Source Generator, named query filters, dan native LeftJoin yang bikin pengelolaan data jadi lebih intuitif.
Nah, dalam panduan ini kita akan membahas setiap opsi penyimpanan data secara detail, lengkap dengan contoh kode yang bisa langsung kamu implementasikan. Mulai dari Preferences untuk data sederhana, SQLite untuk database relasional, EF Core untuk ORM yang powerful, sampai strategi offline-first yang bikin aplikasi kamu tetap responsif tanpa koneksi.
Memahami Opsi Penyimpanan Data di .NET MAUI
Preferences: Untuk Data Sederhana dan Konfigurasi
Preferences adalah opsi penyimpanan paling ringan di .NET MAUI. Mekanisme ini menyimpan data dalam format key-value pair menggunakan native storage di masing-masing platform — SharedPreferences di Android, NSUserDefaults di iOS, dan ApplicationDataContainer di Windows.
Gunakan Preferences untuk menyimpan data non-sensitif yang ukurannya kecil. Misalnya pengaturan tema, bahasa, atau flag boolean sederhana. Menurut dokumentasi Microsoft, operasi Preferences selesai dalam waktu kurang dari 2ms untuk single value — sangat cepat.
// Services/UserPreferencesService.cs
namespace MauiStoreApp.Services;
public interface IUserPreferencesService
{
bool IsDarkMode { get; set; }
string PreferredLanguage { get; set; }
bool HasCompletedOnboarding { get; set; }
void ClearAll();
}
public class UserPreferencesService : IUserPreferencesService
{
public bool IsDarkMode
{
get => Preferences.Default.Get("is_dark_mode", false);
set => Preferences.Default.Set("is_dark_mode", value);
}
public string PreferredLanguage
{
get => Preferences.Default.Get("preferred_language", "id");
set => Preferences.Default.Set("preferred_language", value);
}
public bool HasCompletedOnboarding
{
get => Preferences.Default.Get("has_completed_onboarding", false);
set => Preferences.Default.Set("has_completed_onboarding", value);
}
public void ClearAll()
{
Preferences.Default.Clear();
}
}
Penting: Jangan pernah menyimpan data sensitif seperti token autentikasi atau password di Preferences. Data ini tidak dienkripsi dan bisa diakses dengan mudah. Serius, jangan.
SecureStorage: Untuk Data Sensitif
Nah, untuk data sensitif seperti token OAuth, API key, atau credential user — gunakan SecureStorage. Mekanisme ini memanfaatkan enkripsi platform-native: EncryptedSharedPreferences dengan AES-256 GCM di Android, dan Keychain di iOS. Jadi datanya jauh lebih aman.
// Services/SecureTokenService.cs
namespace MauiStoreApp.Services;
public interface ISecureTokenService
{
Task<string?> GetAccessTokenAsync();
Task SaveAccessTokenAsync(string token);
Task<string?> GetRefreshTokenAsync();
Task SaveRefreshTokenAsync(string token);
Task ClearTokensAsync();
}
public class SecureTokenService : ISecureTokenService
{
private const string AccessTokenKey = "access_token";
private const string RefreshTokenKey = "refresh_token";
public async Task<string?> GetAccessTokenAsync()
{
return await SecureStorage.Default.GetAsync(AccessTokenKey);
}
public async Task SaveAccessTokenAsync(string token)
{
await SecureStorage.Default.SetAsync(AccessTokenKey, token);
}
public async Task<string?> GetRefreshTokenAsync()
{
return await SecureStorage.Default.GetAsync(RefreshTokenKey);
}
public async Task SaveRefreshTokenAsync(string token)
{
await SecureStorage.Default.SetAsync(RefreshTokenKey, token);
}
public async Task ClearTokensAsync()
{
SecureStorage.Default.Remove(AccessTokenKey);
SecureStorage.Default.Remove(RefreshTokenKey);
}
}
Catatan untuk Android: Android Auto Backup bisa mem-backup SharedPreferences yang digunakan SecureStorage. Masalahnya, data yang di-backup tidak bisa didekripsi saat di-restore di device baru (karena encryption key-nya berbeda). Solusinya, tambahkan rule untuk meng-exclude SecureStorage dari Auto Backup, atau tangani exception Java.Security.GeneralSecurityException saat mengakses data yang corrupt. Ini salah satu gotcha yang sering bikin pusing developer Android.
Kapan Menggunakan Database? SQLite.
Preferences dan SecureStorage cocok untuk data sederhana. Tapi begitu kamu perlu menyimpan data terstruktur dengan relasi, melakukan query kompleks, atau mengelola ribuan record — kamu butuh database.
Dan di ekosistem .NET MAUI, SQLite adalah standar yang sudah teruji. Ringan, serverless, dan berjalan langsung di device pengguna. File database-nya disimpan di AppDataDirectory, area private yang hanya bisa diakses oleh aplikasi kamu.
SQLite dengan sqlite-net: Pendekatan Langsung dan Ringan
Setup Awal
Pendekatan pertama menggunakan library sqlite-net-pcl — library open-source yang menyediakan API sederhana untuk operasi CRUD tanpa overhead ORM yang berat. Ini cocok untuk aplikasi yang butuh akses database cepat dan langsung tanpa banyak ceremony.
Install NuGet package yang dibutuhkan:
<!-- File: MauiStoreApp.csproj -->
<ItemGroup>
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.10" />
</ItemGroup>
Membuat Model dan Database Service
Definisikan model data dengan atribut SQLite, kemudian buat service yang mengelola koneksi dan operasi database. Kodenya cukup straightforward:
// Models/Product.cs
using SQLite;
namespace MauiStoreApp.Models;
[Table("products")]
public class Product
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[MaxLength(200), NotNull]
public string Name { get; set; } = string.Empty;
[MaxLength(1000)]
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
[MaxLength(500)]
public string ImageUrl { get; set; } = string.Empty;
[MaxLength(100)]
public string Category { get; set; } = string.Empty;
public bool IsAvailable { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
// Data/SqliteDatabase.cs
using SQLite;
using MauiStoreApp.Models;
namespace MauiStoreApp.Data;
public class SqliteDatabase
{
private SQLiteAsyncConnection? _connection;
private readonly string _dbPath;
public SqliteDatabase()
{
_dbPath = Path.Combine(
FileSystem.AppDataDirectory, "mauistore.db3");
}
private async Task<SQLiteAsyncConnection> GetConnectionAsync()
{
if (_connection is not null)
return _connection;
_connection = new SQLiteAsyncConnection(_dbPath,
SQLiteOpenFlags.ReadWrite |
SQLiteOpenFlags.Create |
SQLiteOpenFlags.SharedCache);
// Aktifkan WAL mode untuk performa lebih baik
await _connection.ExecuteAsync("PRAGMA journal_mode=WAL;");
// Buat tabel jika belum ada
await _connection.CreateTableAsync<Product>();
return _connection;
}
// CREATE
public async Task<int> InsertProductAsync(Product product)
{
var db = await GetConnectionAsync();
return await db.InsertAsync(product);
}
// READ - semua produk
public async Task<List<Product>> GetProductsAsync()
{
var db = await GetConnectionAsync();
return await db.Table<Product>()
.Where(p => p.IsAvailable)
.OrderByDescending(p => p.CreatedAt)
.ToListAsync();
}
// READ - berdasarkan ID
public async Task<Product?> GetProductByIdAsync(int id)
{
var db = await GetConnectionAsync();
return await db.Table<Product>()
.FirstOrDefaultAsync(p => p.Id == id);
}
// READ - pencarian
public async Task<List<Product>> SearchProductsAsync(string query)
{
var db = await GetConnectionAsync();
return await db.Table<Product>()
.Where(p => p.Name.Contains(query) ||
p.Description.Contains(query))
.ToListAsync();
}
// UPDATE
public async Task<int> UpdateProductAsync(Product product)
{
var db = await GetConnectionAsync();
product.UpdatedAt = DateTime.UtcNow;
return await db.UpdateAsync(product);
}
// DELETE
public async Task<int> DeleteProductAsync(Product product)
{
var db = await GetConnectionAsync();
return await db.DeleteAsync(product);
}
}
Perhatikan penggunaan WAL (Write-Ahead Logging) mode di sini. WAL menulis perubahan ke file terpisah dulu sebelum di-commit ke database utama. Ini memungkinkan multiple read bersamaan dengan satu write — sangat meningkatkan performa di aplikasi mobile yang sering melakukan operasi baca. Honestly, WAL mode ini salah satu tweak kecil yang dampaknya cukup signifikan.
Registrasi di DI Container
// MauiProgram.cs
builder.Services.AddSingleton<SqliteDatabase>();
builder.Services.AddSingleton<IUserPreferencesService, UserPreferencesService>();
builder.Services.AddSingleton<ISecureTokenService, SecureTokenService>();
Entity Framework Core + SQLite: ORM yang Powerful untuk Data Kompleks
Kenapa Memilih EF Core?
Kalau sqlite-net cocok untuk skenario sederhana, Entity Framework Core adalah pilihan yang lebih tepat ketika kamu butuh hal-hal seperti relasi antar tabel yang kompleks, migration otomatis saat skema berubah, LINQ query yang kaya, dan change tracking otomatis.
Di EF Core 10, ada beberapa peningkatan yang cukup signifikan untuk pengembangan mobile. Native LeftJoin support yang akhirnya menghilangkan kebutuhan SelectMany/GroupJoin/DefaultIfEmpty yang berbelit-belit itu (siapa yang suka menulis pattern itu, coba?). Lalu ada named query filters yang memungkinkan multiple filter per entity type, dan berbagai optimasi performa LINQ lainnya.
Setup EF Core di .NET MAUI
Install NuGet package yang dibutuhkan:
<!-- File: MauiStoreApp.csproj -->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
</ItemGroup>
Membuat DbContext dan Entities
Bagian ini mungkin sudah familiar kalau kamu pernah pakai EF Core di proyek ASP.NET. Konsepnya sama persis, cuma konteks pemakaiannya yang berbeda:
// Models/Order.cs
namespace MauiStoreApp.Models;
public class Order
{
public int Id { get; set; }
public string CustomerName { get; set; } = string.Empty;
public DateTime OrderDate { get; set; } = DateTime.UtcNow;
public decimal TotalAmount { get; set; }
public OrderStatus Status { get; set; } = OrderStatus.Pending;
// Navigation property
public List<OrderItem> Items { get; set; } = [];
}
public class OrderItem
{
public int Id { get; set; }
public int OrderId { get; set; }
public int ProductId { get; set; }
public string ProductName { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal Subtotal => Quantity * UnitPrice;
// Navigation property
public Order Order { get; set; } = null!;
}
public enum OrderStatus
{
Pending,
Processing,
Shipped,
Delivered,
Cancelled
}
// Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
using MauiStoreApp.Models;
namespace MauiStoreApp.Data;
public class AppDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
private readonly string _dbPath;
public AppDbContext()
{
_dbPath = Path.Combine(
FileSystem.AppDataDirectory, "mauistore_ef.db3");
}
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseSqlite($"Data Source={_dbPath}");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Konfigurasi Order
modelBuilder.Entity<Order>(entity =>
{
entity.HasKey(o => o.Id);
entity.Property(o => o.CustomerName)
.HasMaxLength(200)
.IsRequired();
entity.Property(o => o.TotalAmount)
.HasColumnType("decimal(18,2)");
});
// Konfigurasi OrderItem dengan relasi
modelBuilder.Entity<OrderItem>(entity =>
{
entity.HasKey(oi => oi.Id);
entity.Property(oi => oi.ProductName)
.HasMaxLength(200);
entity.Property(oi => oi.UnitPrice)
.HasColumnType("decimal(18,2)");
entity.HasOne(oi => oi.Order)
.WithMany(o => o.Items)
.HasForeignKey(oi => oi.OrderId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
Catatan penting untuk iOS: Kamu perlu menginisialisasi SQLite provider di iOS. Tambahkan SQLitePCL.Batteries_V2.Init() di konstruktor DbContext atau di AppDelegate jika mengalami masalah koneksi di iOS/Mac Catalyst. Ini sering jadi sumber bug yang agak tricky kalau kamu belum pernah kena.
Repository Pattern untuk Clean Architecture
Untuk menjaga separation of concerns dan memudahkan testing, implementasikan Repository Pattern. Dengan pola ini, ViewModel tidak perlu tahu detail implementasi database — cukup berinteraksi melalui interface. Ini juga bikin unit testing jadi jauh lebih mudah karena kamu tinggal mock repository-nya.
// Repositories/IOrderRepository.cs
using MauiStoreApp.Models;
namespace MauiStoreApp.Repositories;
public interface IOrderRepository
{
Task<List<Order>> GetAllOrdersAsync();
Task<Order?> GetOrderWithItemsAsync(int orderId);
Task<List<Order>> GetOrdersByStatusAsync(OrderStatus status);
Task CreateOrderAsync(Order order);
Task UpdateOrderStatusAsync(int orderId, OrderStatus status);
Task DeleteOrderAsync(int orderId);
}
// Repositories/OrderRepository.cs
using Microsoft.EntityFrameworkCore;
using MauiStoreApp.Data;
using MauiStoreApp.Models;
namespace MauiStoreApp.Repositories;
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public OrderRepository(AppDbContext context)
{
_context = context;
}
public async Task<List<Order>> GetAllOrdersAsync()
{
return await _context.Orders
.OrderByDescending(o => o.OrderDate)
.ToListAsync();
}
public async Task<Order?> GetOrderWithItemsAsync(int orderId)
{
return await _context.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == orderId);
}
public async Task<List<Order>> GetOrdersByStatusAsync(
OrderStatus status)
{
return await _context.Orders
.Where(o => o.Status == status)
.OrderByDescending(o => o.OrderDate)
.ToListAsync();
}
public async Task CreateOrderAsync(Order order)
{
order.TotalAmount = order.Items.Sum(i => i.Subtotal);
_context.Orders.Add(order);
await _context.SaveChangesAsync();
}
public async Task UpdateOrderStatusAsync(
int orderId, OrderStatus status)
{
var order = await _context.Orders.FindAsync(orderId);
if (order is not null)
{
order.Status = status;
await _context.SaveChangesAsync();
}
}
public async Task DeleteOrderAsync(int orderId)
{
var order = await _context.Orders.FindAsync(orderId);
if (order is not null)
{
_context.Orders.Remove(order);
await _context.SaveChangesAsync();
}
}
}
Registrasi EF Core di DI Container
// MauiProgram.cs
// Registrasi DbContext
builder.Services.AddDbContext<AppDbContext>();
// Registrasi Repository
builder.Services.AddTransient<IOrderRepository, OrderRepository>();
Pastikan kamu memanggil Database.EnsureCreated() saat pertama kali aplikasi dijalankan untuk membuat database dan tabel:
// App.xaml.cs
using MauiStoreApp.Data;
namespace MauiStoreApp;
public partial class App : Application
{
public App(AppDbContext dbContext)
{
InitializeComponent();
// Pastikan database sudah dibuat
dbContext.Database.EnsureCreated();
}
}
sqlite-net vs Entity Framework Core: Kapan Pakai yang Mana?
Ini pertanyaan yang sering banget muncul, dan jujur jawabannya tergantung kebutuhan proyek kamu:
- Gunakan sqlite-net kalau skema database sederhana tanpa banyak relasi, kamu butuh footprint minimal dan performa maksimal, aplikasi cuma butuh operasi CRUD basic, atau kamu pengen kontrol penuh terhadap SQL query.
- Gunakan EF Core kalau skema database kompleks dengan banyak relasi (one-to-many, many-to-many), kamu butuh migration otomatis saat skema berubah, tim sudah familiar dengan EF Core dari proyek web/desktop, atau kamu butuh change tracking dan lazy loading.
Satu hal yang perlu diingat: EF Core punya footprint yang lebih besar dibandingkan sqlite-net. Kalau ukuran APK jadi concern utama dan skema database-nya sederhana, sqlite-net biasanya pilihan yang lebih tepat.
Strategi Offline-First: Membuat Aplikasi yang Tangguh
Prinsip Dasar Offline-First
Filosofi offline-first itu sebetulnya sederhana: perlakukan koneksi internet sebagai enhancement, bukan requirement. Semua operasi baca dan tulis dilakukan terhadap database lokal dulu. Sinkronisasi dengan server dilakukan di background ketika koneksi tersedia.
Manfaatnya sangat nyata. User bisa terus menggunakan aplikasi tanpa gangguan meskipun berada di area tanpa sinyal, dan UI selalu responsif karena data di-load dari storage lokal. Ini bukan sekadar fitur "nice to have" — di banyak wilayah Indonesia yang koneksinya masih fluktuatif, ini justru jadi kebutuhan utama.
Mendeteksi Status Koneksi
.NET MAUI menyediakan API Connectivity untuk memonitor status jaringan. Gunakan ini sebagai trigger untuk memulai atau menghentikan proses sinkronisasi:
// Services/ConnectivityService.cs
namespace MauiStoreApp.Services;
public interface IConnectivityService
{
bool IsConnected { get; }
event EventHandler<bool> ConnectivityChanged;
}
public class ConnectivityService : IConnectivityService, IDisposable
{
public bool IsConnected =>
Connectivity.Current.NetworkAccess == NetworkAccess.Internet;
public event EventHandler<bool>? ConnectivityChanged;
public ConnectivityService()
{
Connectivity.Current.ConnectivityChanged += OnConnectivityChanged;
}
private void OnConnectivityChanged(
object? sender, ConnectivityChangedEventArgs e)
{
var isConnected =
e.NetworkAccess == NetworkAccess.Internet;
ConnectivityChanged?.Invoke(this, isConnected);
}
public void Dispose()
{
Connectivity.Current.ConnectivityChanged -= OnConnectivityChanged;
}
}
Sync Queue: Antrian Operasi untuk Sinkronisasi
Ini komponen kunci dari arsitektur offline-first — sync queue. Intinya, setiap kali user membuat, mengubah, atau menghapus data, operasi tersebut dicatat dalam antrian. Ketika koneksi tersedia, antrian diproses secara berurutan. Simpel secara konsep, tapi implementasinya perlu hati-hati.
// Models/SyncOperation.cs
using SQLite;
namespace MauiStoreApp.Models;
[Table("sync_queue")]
public class SyncOperation
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[NotNull]
public string EntityType { get; set; } = string.Empty;
public int EntityId { get; set; }
[NotNull]
public string OperationType { get; set; } = string.Empty; // Create, Update, Delete
// JSON payload dari data yang akan disinkronkan
[NotNull]
public string Payload { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public int RetryCount { get; set; } = 0;
public SyncStatus Status { get; set; } = SyncStatus.Pending;
}
public enum SyncStatus
{
Pending,
InProgress,
Completed,
Failed
}
// Services/SyncService.cs
using System.Text.Json;
using MauiStoreApp.Data;
using MauiStoreApp.Models;
namespace MauiStoreApp.Services;
public class SyncService
{
private readonly SqliteDatabase _db;
private readonly IConnectivityService _connectivity;
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _syncLock = new(1, 1);
private const int MaxRetries = 3;
public SyncService(
SqliteDatabase db,
IConnectivityService connectivity,
HttpClient httpClient)
{
_db = db;
_connectivity = connectivity;
_httpClient = httpClient;
// Mulai sync otomatis saat koneksi tersedia
_connectivity.ConnectivityChanged += async (_, isConnected) =>
{
if (isConnected)
await ProcessSyncQueueAsync();
};
}
// Tambahkan operasi ke antrian sync
public async Task EnqueueAsync<T>(
T entity, string operationType) where T : class
{
var operation = new SyncOperation
{
EntityType = typeof(T).Name,
OperationType = operationType,
Payload = JsonSerializer.Serialize(entity),
CreatedAt = DateTime.UtcNow
};
await _db.InsertSyncOperationAsync(operation);
// Coba sync langsung jika ada koneksi
if (_connectivity.IsConnected)
await ProcessSyncQueueAsync();
}
// Proses antrian sync
public async Task ProcessSyncQueueAsync()
{
if (!await _syncLock.WaitAsync(TimeSpan.Zero))
return; // Sync sudah berjalan
try
{
var pendingOps = await _db.GetPendingSyncOperationsAsync();
foreach (var op in pendingOps)
{
if (!_connectivity.IsConnected)
break; // Berhenti jika koneksi terputus
try
{
await SendToServerAsync(op);
op.Status = SyncStatus.Completed;
await _db.UpdateSyncOperationAsync(op);
}
catch (HttpRequestException)
{
op.RetryCount++;
op.Status = op.RetryCount >= MaxRetries
? SyncStatus.Failed
: SyncStatus.Pending;
await _db.UpdateSyncOperationAsync(op);
}
}
}
finally
{
_syncLock.Release();
}
}
private async Task SendToServerAsync(SyncOperation op)
{
var endpoint = $"/api/{op.EntityType.ToLower()}s";
var content = new StringContent(
op.Payload, System.Text.Encoding.UTF8, "application/json");
HttpResponseMessage response = op.OperationType switch
{
"Create" => await _httpClient.PostAsync(endpoint, content),
"Update" => await _httpClient.PutAsync(
$"{endpoint}/{op.EntityId}", content),
"Delete" => await _httpClient.DeleteAsync(
$"{endpoint}/{op.EntityId}"),
_ => throw new InvalidOperationException(
$"Unknown operation: {op.OperationType}")
};
response.EnsureSuccessStatusCode();
}
}
Conflict Resolution: Menangani Konflik Data
Konflik terjadi ketika data yang sama dimodifikasi secara bersamaan. Contoh klasiknya: user mengubah data saat offline, dan admin juga mengubah data yang sama di server. Ada beberapa strategi yang bisa kamu pilih:
- Last Write Wins (LWW): Strategi paling sederhana — perubahan terakhir berdasarkan timestamp yang menang. Cocok untuk data non-kritis seperti preferensi user. Kelemahannya, perubahan yang lebih lama bisa hilang tanpa notifikasi.
- Server Wins: Data server selalu dianggap otoritatif. Perubahan lokal yang berkonflik akan ditimpa. Cocok untuk data yang di-manage secara sentral.
- Client Wins: Kebalikannya — perubahan lokal user selalu diutamakan. Cocok untuk data personal seperti catatan atau draft.
- Manual Resolution: Tampilkan kedua versi ke user dan biarkan mereka memilih. Paling aman tapi butuh UX yang baik. Cocok untuk data kritis seperti dokumen kolaboratif.
Untuk sebagian besar aplikasi mobile, menurut saya kombinasi Last Write Wins untuk data non-kritis dan Manual Resolution untuk data penting adalah pendekatan yang paling pragmatis. Jangan over-engineer bagian ini kecuali memang ada kebutuhan khusus.
Data Caching: Mempercepat Akses Data dari API
Caching adalah strategi pelengkap yang bikin aplikasi terasa cepat. Dengan menyimpan data dari API secara lokal untuk sementara waktu, waktu retrieval data bisa berkurang hingga 90% dibandingkan selalu mengambil dari network. Angka ini bukan asal-asalan — coba bandingkan sendiri latency akses SQLite lokal vs HTTP request ke server.
// Services/CachedApiService.cs
using System.Text.Json;
using MauiStoreApp.Models;
namespace MauiStoreApp.Services;
public class CachedApiService
{
private readonly HttpClient _httpClient;
private readonly SqliteDatabase _db;
private readonly IConnectivityService _connectivity;
// Durasi cache default: 15 menit
private static readonly TimeSpan DefaultCacheDuration =
TimeSpan.FromMinutes(15);
public CachedApiService(
HttpClient httpClient,
SqliteDatabase db,
IConnectivityService connectivity)
{
_httpClient = httpClient;
_db = db;
_connectivity = connectivity;
}
public async Task<List<Product>> GetProductsAsync(
bool forceRefresh = false)
{
// Cek apakah perlu refresh dari server
if (!forceRefresh && !IsCacheExpired("products"))
{
var cached = await _db.GetProductsAsync();
if (cached.Count > 0)
return cached;
}
// Coba ambil dari API jika online
if (_connectivity.IsConnected)
{
try
{
var response = await _httpClient
.GetStringAsync("/api/products");
var products = JsonSerializer
.Deserialize<List<Product>>(response,
new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? [];
// Simpan ke database lokal sebagai cache
foreach (var product in products)
await _db.InsertOrUpdateProductAsync(product);
SetCacheTimestamp("products");
return products;
}
catch (HttpRequestException)
{
// Fallback ke data lokal jika API gagal
}
}
// Fallback: kembalikan data lokal
return await _db.GetProductsAsync();
}
private bool IsCacheExpired(string key)
{
var lastSync = Preferences.Default.Get(
$"cache_{key}_timestamp", DateTime.MinValue);
return DateTime.UtcNow - lastSync > DefaultCacheDuration;
}
private void SetCacheTimestamp(string key)
{
Preferences.Default.Set(
$"cache_{key}_timestamp", DateTime.UtcNow);
}
}
Pattern ini mengikuti prinsip stale-while-revalidate: tampilkan data dari cache segera supaya UX tetap responsif, sambil mengambil data terbaru dari server di background. User nggak perlu melihat loading spinner setiap kali buka halaman — data langsung muncul dari cache lokal.
Best Practices: Rangkuman Pengelolaan Data di .NET MAUI
Sebelum menutup panduan ini, berikut beberapa best practice yang worth untuk diingat:
- Selalu gunakan async/await untuk semua operasi data. Operasi sinkron di main thread akan memblokir UI dan bikin aplikasi terasa tidak responsif. Ini non-negotiable.
- Pilih mekanisme storage yang tepat: Preferences untuk settings sederhana, SecureStorage untuk data sensitif, SQLite untuk data terstruktur.
- Aktifkan WAL mode di SQLite untuk performa concurrent read/write yang lebih baik.
- Implementasikan Repository Pattern untuk memisahkan logika data dari UI, memudahkan testing, dan memungkinkan pergantian implementasi database tanpa mengubah kode lain.
- Jangan simpan file besar di database. Untuk gambar atau dokumen, simpan file-nya di filesystem dan hanya simpan path-nya di database.
- Handle error dengan graceful. Database bisa corrupt, storage bisa penuh. Selalu tangani exception dan berikan feedback yang jelas ke user.
- Pertimbangkan ukuran database. Di perangkat mobile, storage itu terbatas. Implementasikan strategi cleanup untuk menghapus data lama yang sudah nggak dibutuhkan.
- Test di semua platform target. SQLite dan SecureStorage bisa berperilaku sedikit berbeda di Android, iOS, dan Windows. Selalu test di device atau emulator untuk setiap platform.
FAQ: Pertanyaan yang Sering Diajukan
Apakah lebih baik menggunakan SQLite langsung atau Entity Framework Core di .NET MAUI?
Tergantung kompleksitas proyek. Untuk aplikasi dengan skema database sederhana dan sedikit relasi, sqlite-net-pcl sudah lebih dari cukup dan punya footprint yang lebih kecil. Untuk aplikasi dengan banyak relasi, kebutuhan migration, dan tim yang sudah familiar dengan EF Core, Entity Framework Core jelas pilihan yang lebih tepat. Di .NET 10, EF Core juga sudah mendapat banyak optimasi performa yang membuatnya semakin viable untuk mobile.
Bagaimana cara menangani migrasi database saat update aplikasi?
Dengan sqlite-net, kamu perlu mengelola migrasi secara manual — biasanya dengan menyimpan versi database di Preferences dan menjalankan ALTER TABLE statement yang sesuai saat terdeteksi versi lama. Dengan EF Core, kamu bisa menggunakan Database.EnsureCreated() untuk skenario sederhana, atau implementasi code-first migration dengan memanggil Database.Migrate(). Yang penting, selalu backup data sebelum melakukan migrasi skema di production.
Apakah .NET MAUI mendukung sinkronisasi data offline secara bawaan?
Tidak secara bawaan, sayangnya. Tapi Microsoft menyediakan DataSync Framework melalui Azure Mobile Apps yang mendukung offline sync dengan conflict resolution bawaan. Untuk solusi yang lebih fleksibel, kamu bisa membangun sync engine sendiri menggunakan sync queue pattern seperti yang dibahas di artikel ini, atau coba library open-source seperti NubeSync yang menyediakan automatic merge conflict resolution per-field.
Bagaimana cara mengamankan database SQLite di aplikasi .NET MAUI?
SQLite secara default nggak mengenkripsi data. Untuk mengamankan database, kamu bisa menggunakan SQLCipher melalui package sqlite-net-sqlcipher yang menyediakan enkripsi AES-256 untuk seluruh file database. Selain itu, simpan database di AppDataDirectory yang sudah sandboxed per-aplikasi, dan jangan pernah menyimpan credential atau token di database biasa — gunakan SecureStorage untuk data sensitif.
Berapa batas ukuran database SQLite yang ideal untuk aplikasi mobile?
Secara teknis, SQLite mendukung database hingga 281 terabyte. Tapi untuk aplikasi mobile, best practice-nya adalah menjaga ukuran database di bawah 50-100MB supaya nggak membebani storage device. Implementasikan strategi cleanup periodik untuk menghapus data lama, gunakan pagination saat menampilkan data dalam jumlah besar, dan pertimbangkan untuk menyimpan data media (gambar, video) di filesystem terpisah.