# iOS Offline-First Mimari: Çevrimdışı Çalışan Uygulamalar
Kullanıcılarınız metroda, uçakta veya kötü internet bağlantısı olan bir bölgedeyken uygulamanız ne yapıyor? "İnternet bağlantısı yok" mesajı göstermek 2026'da kabul edilemez. Offline-first mimari, uygulamanızın önce local veriyle çalışmasını, ağ bağlantısı olduğunda senkronize olmasını sağlar. Bu rehberde, sıfırdan production-ready bir offline-first mimari kuracağız.
Felsefe: "Offline, bir hata durumu değil — varsayılan durumdur." Bu zihniyetle tasarlanan uygulamalar, online olduğunda da daha hızlı çalışır.
İçindekiler
- Offline-First Nedir?
- Mimari Katmanlar
- Local Storage Stratejisi
- Sync Engine Tasarımı
- Conflict Resolution
- Queue-Based Operations
- Network Reachability
- Karşılaştırma Tablosu
- ALTIN İPUCU
- Sonuç ve Öneriler
1. Offline-First Nedir?
Offline-first, verinin öncelikle local'de depolanıp işlenmesi ve ağ bağlantısı mevcut olduğunda sunucuyla senkronize edilmesi yaklaşımıdır.
Online-First vs Offline-First
Özellik | Online-First | Offline-First | Cache-First |
|---|---|---|---|
**Veri kaynağı** | Sunucu | Local DB | Cache (geçici) |
**Offline deneyim** | Hata mesajı | Tam fonksiyonel | Kısıtlı (stale data) |
**İlk açılış** | Yavaş (network) | Hızlı (local) | Orta (cache hit/miss) |
**Veri tutarlılığı** | Garantili | Eventual consistency | Garantisiz |
**Complexity** | Düşük | Yüksek | Orta |
**UX kalitesi** | Bağlantıya bağlı | Her zaman iyi | Değişken |
2. Mimari Katmanlar
swift
1// MARK: - Offline-First Architecture Layers2 3// 1. PRESENTATION LAYER4// SwiftUI View'lar sadece local veriyi gosterir5// Sync durumu UI'da gosterilir (synced, pending, conflict)6 7// 2. DOMAIN LAYER8// Use case'ler her zaman local repository'yi kullanir9protocol NoteRepository {10 func getAll() async throws -> [Note]11 func getById(_ id: UUID) async throws -> Note?12 func save(_ note: Note) async throws13 func delete(_ id: UUID) async throws14}15 16// 3. DATA LAYER17// Local + Remote senkronizasyonu yonetir18final class OfflineFirstNoteRepository: NoteRepository {19 private let localStore: LocalNoteStore20 private let remoteAPI: NoteAPI21 private let syncEngine: SyncEngine22 private let operationQueue: OperationQueue23 24 init(25 localStore: LocalNoteStore,26 remoteAPI: NoteAPI,27 syncEngine: SyncEngine,28 operationQueue: OperationQueue29 ) {30 self.localStore = localStore31 self.remoteAPI = remoteAPI32 self.syncEngine = syncEngine33 self.operationQueue = operationQueue34 }35 36 func getAll() async throws -> [Note] {37 // Her zaman local'den oku (hizli!)38 return try await localStore.fetchAll()39 }40 41 func save(_ note: Note) async throws {42 // 1. Once local'e kaydet (aninda)43 var localNote = note44 localNote.syncStatus = .pending45 localNote.updatedAt = Date()46 try await localStore.save(localNote)47 48 // 2. Sync queue'ya ekle (arkaplanda gonderilecek)49 let operation = SyncOperation(50 type: .upsert,51 entityType: "note",52 entityId: note.id.uuidString,53 payload: try JSONEncoder().encode(note),54 createdAt: Date()55 )56 try await operationQueue.enqueue(operation)57 58 // 3. Sync'i tetikle (best effort)59 await syncEngine.triggerSync()60 }61 62 func delete(_ id: UUID) async throws {63 // Soft delete - sync sonrasi gercek silme64 try await localStore.markDeleted(id)65 66 let operation = SyncOperation(67 type: .delete,68 entityType: "note",69 entityId: id.uuidString,70 payload: nil,71 createdAt: Date()72 )73 try await operationQueue.enqueue(operation)74 await syncEngine.triggerSync()75 }76 77 func getById(_ id: UUID) async throws -> Note? {78 return try await localStore.fetchById(id)79 }80}3. Local Storage Stratejisi
swift
1// MARK: - SwiftData ile Local Store2import SwiftData3 4@Model5final class NoteEntity {6 @Attribute(.unique) var id: UUID7 var title: String8 var content: String9 var createdAt: Date10 var updatedAt: Date11 var syncStatus: String // "synced", "pending", "conflict"12 var isDeleted: Bool13 var serverVersion: Int14 var localVersion: Int15 16 init(17 id: UUID = UUID(),18 title: String,19 content: String,20 createdAt: Date = Date(),21 updatedAt: Date = Date(),22 syncStatus: String = "pending",23 isDeleted: Bool = false,24 serverVersion: Int = 0,25 localVersion: Int = 126 ) {27 self.id = id28 self.title = title29 self.content = content30 self.createdAt = createdAt31 self.updatedAt = updatedAt32 self.syncStatus = syncStatus33 self.isDeleted = isDeleted34 self.serverVersion = serverVersion35 self.localVersion = localVersion36 }37}38 39// MARK: - Local Store Implementation40final class SwiftDataNoteStore: LocalNoteStore {41 private let container: ModelContainer42 43 init() throws {44 let schema = Schema([NoteEntity.self])45 let config = ModelConfiguration(isStoredInMemoryOnly: false)46 self.container = try ModelContainer(for: schema, configurations: [config])47 }48 49 func fetchAll() async throws -> [Note] {50 let context = ModelContext(container)51 let descriptor = FetchDescriptor<NoteEntity>(52 predicate: #Predicate { !$0.isDeleted },53 sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]54 )55 let entities = try context.fetch(descriptor)56 return entities.map { $0.toDomain() }57 }58 59 func save(_ note: Note) async throws {60 let context = ModelContext(container)61 let entity = NoteEntity(from: note)62 context.insert(entity)63 try context.save()64 }65 66 func markDeleted(_ id: UUID) async throws {67 let context = ModelContext(container)68 let descriptor = FetchDescriptor<NoteEntity>(69 predicate: #Predicate { entity in entity.id == id }70 )71 if let entity = try context.fetch(descriptor).first {72 entity.isDeleted = true73 entity.syncStatus = "pending"74 try context.save()75 }76 }77 78 func fetchById(_ id: UUID) async throws -> Note? {79 let context = ModelContext(container)80 let descriptor = FetchDescriptor<NoteEntity>(81 predicate: #Predicate { entity in entity.id == id }82 )83 return try context.fetch(descriptor).first?.toDomain()84 }85}Easter Egg
Gizli bir bilgi buldun!
Bu bölümde gizli bir bilgi var. Keşfetmek ister misin?
4. Sync Engine Tasarımı
swift
1// MARK: - Sync Engine2actor SyncEngine {3 private let operationQueue: OperationQueue4 private let remoteAPI: RemoteAPI5 private let localStore: LocalNoteStore6 private var isSyncing = false7 private let reachability: NetworkReachability8 9 init(10 operationQueue: OperationQueue,11 remoteAPI: RemoteAPI,12 localStore: LocalNoteStore,13 reachability: NetworkReachability14 ) {15 self.operationQueue = operationQueue16 self.remoteAPI = remoteAPI17 self.localStore = localStore18 self.reachability = reachability19 }20 21 func triggerSync() async {22 guard !isSyncing else { return }23 guard reachability.isConnected else { return }24 25 isSyncing = true26 defer { isSyncing = false }27 28 do {29 // 1. Bekleyen local degisiklikleri gonder (Push)30 try await pushLocalChanges()31 32 // 2. Sunucudan yeni degisiklikleri cek (Pull)33 try await pullRemoteChanges()34 35 // 3. Conflict'leri coz36 try await resolveConflicts()37 38 } catch {39 LogService.error("Sync hatasi: \(error)")40 }41 }42 43 private func pushLocalChanges() async throws {44 let pendingOps = try await operationQueue.getPending()45 46 for operation in pendingOps {47 do {48 try await remoteAPI.execute(operation)49 try await operationQueue.markCompleted(operation.id)50 } catch let error as APIError where error.isConflict {51 try await operationQueue.markConflict(operation.id)52 } catch let error as APIError where error.isRetryable {53 // Yeniden denenecek, birakalim54 continue55 } catch {56 try await operationQueue.markFailed(operation.id, error: error)57 }58 }59 }60 61 private func pullRemoteChanges() async throws {62 let lastSyncTimestamp = UserDefaults.standard.double(forKey: "last_sync")63 let remoteChanges = try await remoteAPI.getChanges(since: lastSyncTimestamp)64 65 for change in remoteChanges {66 try await localStore.applyRemoteChange(change)67 }68 69 UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "last_sync")70 }71 72 private func resolveConflicts() async throws {73 let conflicts = try await localStore.getConflicts()74 for conflict in conflicts {75 let resolution = ConflictResolver.resolve(76 local: conflict.localVersion,77 remote: conflict.remoteVersion,78 strategy: .lastWriterWins79 )80 try await localStore.applyResolution(conflict.id, resolution: resolution)81 }82 }83}5. Conflict Resolution
Conflict, aynı verinin hem local hem remote'da değiştirilmesi durumunda ortaya çıkar.
Conflict Resolution Stratejileri
Strateji | Karmaşıklık | Veri Kaybı | Kullanım |
|---|---|---|---|
**Last Writer Wins** | Düşük | Olabilir | Basit uygulamalar |
**Server Wins** | Düşük | Local kayıp | Otoriter sunucu |
**Client Wins** | Düşük | Remote kayıp | Offline-öncelikli |
**Manual Merge** | Yüksek | Yok (kullanıcı karar) | Doküman editörleri |
**Field-Level Merge** | Orta | Minimal | Yapılandırılmış veri |
**CRDT** | Çok yüksek | Yok | Gerçek zamanlı collab |
swift
1// MARK: - Conflict Resolver2enum ConflictStrategy {3 case lastWriterWins4 case serverWins5 case clientWins6 case fieldLevelMerge7}8 9struct ConflictResolver {10 static func resolve(11 local: VersionedEntity,12 remote: VersionedEntity,13 strategy: ConflictStrategy14 ) -> VersionedEntity {15 switch strategy {16 case .lastWriterWins:17 return local.updatedAt > remote.updatedAt ? local : remote18 19 case .serverWins:20 return remote21 22 case .clientWins:23 return local24 25 case .fieldLevelMerge:26 return mergeFields(local: local, remote: remote)27 }28 }29 30 private static func mergeFields(31 local: VersionedEntity,32 remote: VersionedEntity33 ) -> VersionedEntity {34 // Alan bazli birlestirme35 var merged = remote36 // Local'de degisen alanlari koru37 for field in local.changedFields {38 if !remote.changedFields.contains(field) {39 merged.setValue(local.value(for: field), for: field)40 }41 }42 return merged43 }44}6. Queue-Based Operations
Offline işlemler bir kuyrukta birikir ve bağlantı geldiğinde sırayla işlenir.
swift
1// MARK: - Operation Queue (Persistent)2actor PersistentOperationQueue: OperationQueue {3 private let store: UserDefaults4 private let key = "pending_sync_operations"5 6 init(store: UserDefaults = .standard) {7 self.store = store8 }9 10 func enqueue(_ operation: SyncOperation) async throws {11 var ops = getPendingSync()12 ops.append(operation)13 savePendingSync(ops)14 }15 16 func getPending() async throws -> [SyncOperation] {17 return getPendingSync()18 }19 20 func markCompleted(_ id: UUID) async throws {21 var ops = getPendingSync()22 ops.removeAll { $0.id == id }23 savePendingSync(ops)24 }25 26 func markConflict(_ id: UUID) async throws {27 var ops = getPendingSync()28 if let index = ops.firstIndex(where: { $0.id == id }) {29 ops[index].status = .conflict30 }31 savePendingSync(ops)32 }33 34 func markFailed(_ id: UUID, error: Error) async throws {35 var ops = getPendingSync()36 if let index = ops.firstIndex(where: { $0.id == id }) {37 ops[index].status = .failed38 ops[index].retryCount += 139 }40 savePendingSync(ops)41 }42 43 private func getPendingSync() -> [SyncOperation] {44 guard let data = store.data(forKey: key) else { return [] }45 return (try? JSONDecoder().decode([SyncOperation].self, from: data)) ?? []46 }47 48 private func savePendingSync(_ ops: [SyncOperation]) {49 if let data = try? JSONEncoder().encode(ops) {50 store.set(data, forKey: key)51 }52 }53}7. Network Reachability
swift
1import Network2 3// MARK: - Network Monitor4final class NetworkReachability: ObservableObject {5 @Published var isConnected = true6 @Published var connectionType: NWInterface.InterfaceType?7 8 private let monitor = NWPathMonitor()9 private let queue = DispatchQueue(label: "com.app.network-monitor")10 11 init() {12 monitor.pathUpdateHandler = { [weak self] path in13 DispatchQueue.main.async {14 self?.isConnected = path.status == .satisfied15 self?.connectionType = path.availableInterfaces.first?.type16 }17 }18 monitor.start(queue: queue)19 }20 21 deinit {22 monitor.cancel()23 }24}swift
1// MARK: - Optimistic UI Pattern2enum SyncStatus: String, Codable {3 case synced // Sunucuyla eslesik4 case pending // Gonderilmeyi bekliyor5 case conflict // Cakisma tespit edildi6 case failed // Gonderilemedi7 8 var icon: String {9 switch self {10 case .synced: return "checkmark.circle.fill"11 case .pending: return "arrow.triangle.2.circlepath"12 case .conflict: return "exclamationmark.triangle.fill"13 case .failed: return "xmark.circle.fill"14 }15 }16}17 18// SwiftUI'da kullanim19struct NoteRowView: View {20 let note: Note21 22 var body: some View {23 HStack {24 VStack(alignment: .leading) {25 Text(note.title).font(.headline)26 Text(note.updatedAt, style: .relative).font(.caption).foregroundStyle(.secondary)27 }28 Spacer()29 Image(systemName: note.syncStatus.icon)30 .foregroundStyle(note.syncStatus == .synced ? .green : .orange)31 .symbolEffect(.pulse, isActive: note.syncStatus == .pending)32 }33 }34}ALTIN İPUCU
Bu yazının en değerli bilgisi
Bu ipucu, yazının en önemli çıkarımını içeriyor.
Okuyucu Ödülü
Tebrikler! Bu yazıyı sonuna kadar okuduğun için sana özel bir hediyem var:
Sonuç ve Öneriler
Offline-first mimari, kullanıcı deneyimini dramatik şekilde iyileştirir. Local-first yaklaşımla uygulamanız her zaman hızlı açılır, her zaman çalışır ve bağlantı geldiğinde şeffaf şekilde senkronize olur. Karmaşıklığı kabullenin ama doğru soyutlamalarla yönetilebilir kılın. SyncEngine'i actor olarak tasarlayın, conflict resolution stratejinizi erken belirleyin ve her zaman offline senaryoları test edin.

