Modular Architecture for Native macOS Apps: Frameworks, Actors, and File Provider

M
Matthew Diakonov

Modular Architecture for Native macOS Apps: Frameworks, Actors, and File Provider

Building a native macOS app with real complexity - file syncing, background services, deep OS integration - demands clean architecture from day one. The pattern that works: split into modular frameworks and use Swift actors to handle concurrency in the sync engine.

Getting this right early saves weeks of debugging later. Getting it wrong means your app works in demos but fails under the edge cases that real users hit constantly.

Why Modular Frameworks Instead of a Monolith

A monolithic macOS app accumulates coupling fast. Your UI starts importing networking types. Your sync engine starts calling view update methods. Tests become impossible because everything depends on everything else. Build times creep up because a change to one file recompiles half the project.

The split that works well for sync-heavy apps:

  • Models - pure data types, no business logic, no UI dependencies
  • Networking - API clients, authentication, retry logic
  • SyncEngine - the core coordination logic, heavily tested, no framework dependencies
  • FileProvider - thin adapter between Apple's framework and the sync engine
  • UI - SwiftUI views and view models
  • Utilities - shared helpers, logging, error types

Each framework compiles independently. AI agents modifying the sync engine do not accidentally break the UI. Tests for the sync engine run without starting the full app. The build system parallelizes compilation across frameworks.

Swift 6.1 (released March 2025) made module boundaries stricter with its default concurrency checking, which means architectural mistakes that would have been runtime crashes in Swift 5 are now compile-time errors. The modular split makes it easier to reason about which concurrency domain each piece of code lives in.

Actor-Based Sync Engine

The sync engine is where race conditions live. Multiple things happen simultaneously: the user edits a file, a background sync completes, the network drops, another device uploads a conflicting version. If you handle these with locks or queues, you will eventually deadlock or miss an update.

Swift actors serialize access to mutable state automatically. The compiler enforces isolation at compile time in Swift 6. A sync engine built as an actor cannot have data races by construction:

// SyncEngine.swift - lives in the SyncEngine framework
actor SyncEngine {
    private var syncQueue: [SyncTask] = []
    private var activeUploads: [String: UploadTask] = [:]
    private let networkClient: NetworkClient

    init(networkClient: NetworkClient) {
        self.networkClient = networkClient
    }

    func enqueue(_ task: SyncTask) async {
        syncQueue.append(task)
        await processQueue()
    }

    private func processQueue() async {
        guard let task = syncQueue.first else { return }
        syncQueue.removeFirst()

        switch task {
        case .upload(let fileURL):
            await upload(fileURL)
        case .download(let remoteID):
            await download(remoteID)
        case .resolveConflict(let local, let remote):
            await resolveConflict(local: local, remote: remote)
        }

        await processQueue()
    }

    private func upload(_ url: URL) async {
        let key = url.path
        guard activeUploads[key] == nil else { return } // dedup in-flight uploads

        let task = UploadTask(url: url)
        activeUploads[key] = task

        defer { activeUploads.removeValue(forKey: key) }

        do {
            try await networkClient.upload(url)
        } catch {
            // Requeue with backoff
            await enqueue(.upload(url))
        }
    }
}

The actor guarantees that activeUploads is never accessed from two places simultaneously. No locks, no queues, no manual synchronization. The compiler verifies correctness.

File Provider as a Thin Adapter

Apple's File Provider framework integrates your sync service with Finder. Files appear in the sidebar, users can browse and open them, and the system handles lazy downloads when users access evicted items.

The framework is notoriously under-documented. The official docs cover happy paths. The edge cases - enumeration invalidation, coordinated writes, eviction handling, the coordination between your extension and the system's fileproviderd daemon - require reading developer forum posts, reverse-engineering working examples, and filing feedback reports with Apple.

The key architectural decision: treat the File Provider extension as a thin adapter. It translates between what NSFileProviderManager expects and what your sync engine does. It owns no business logic. It holds no state beyond what is needed for the current enumeration.

// FileProviderExtension.swift - lives in the FileProvider framework
// Thin adapter: translates between Apple's API and the sync engine
class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {

    // SyncEngine is owned by the main app process via XPC
    // The extension communicates with it through a protocol
    private let syncBridge: SyncEngineBridge

    required init(domain: NSFileProviderDomain) {
        self.syncBridge = SyncEngineBridge(domain: domain)
        super.init()
    }

    func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier,
                       version requestedVersion: NSFileProviderItemVersion?,
                       request: NSFileProviderRequest,
                       completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void)
                       -> Progress {

        let progress = Progress(totalUnitCount: 100)

        Task {
            do {
                let (url, item) = try await syncBridge.fetchContents(
                    identifier: itemIdentifier,
                    progress: progress
                )
                completionHandler(url, item, nil)
            } catch {
                completionHandler(nil, nil, error)
            }
        }

        return progress
    }
}

The extension does two things: accept the system's request, delegate to the bridge. The bridge handles the actual sync logic through the actor-based engine. This way the sync engine has full test coverage independent of the File Provider.

The Hard Parts: Conflict Resolution and Recovery

The happy path - download a file, open it, save it - is about 20% of what your sync engine needs to handle. The other 80% is edge cases.

Conflict resolution. Two devices edit the same file before syncing. The server has version A, your local copy is version B. The naive solution is "last write wins." The right solution depends on the file type: for text, three-way merge works; for binary files, you need to keep both copies and let the user decide; for structured formats like SQLite, application-specific merge logic is required.

Network drops mid-upload. An interrupted upload leaves the server with a partial file. You need to track upload progress persistently (in Core Data or SQLite, not in memory) so that a crash or sleep/wake cycle does not restart from zero. Resumable uploads require storing the upload URL and byte offset.

Corrupted local state. The local database gets corrupted - by a crash, a disk error, or a bug. The sync engine needs to detect inconsistency and rebuild from the server's source of truth without losing local changes. This means keeping a log of local edits that have not yet been acknowledged by the server.

Each of these is a separate, testable unit inside the actor. You can write a test that puts the sync engine into "file in conflict" state and verify it produces the right output. You cannot easily test this in a monolith where the sync logic is tangled with UI updates.

Build the Architecture Before Writing Features

If you are starting a macOS app that needs sync, invest in the architecture before writing a single feature. The six-framework split takes a day to set up correctly. It pays back immediately - the first time you change the networking layer without touching the sync engine, the first time you add a test for conflict resolution without needing the full app running.

The time to do this is before you have 50,000 lines of code in a monolith.

This post was inspired by a discussion on r/opensource by u/iAlex11.

Fazm is an open source macOS AI agent. Open source on GitHub.

More on This Topic

Related Posts