The Company App: Offline-First iOS Case Study for SME Operations
The Company App is an offline-first iOS business operations tool for a distribution company with warehouse and office teams. This case study covers the architectural constraints, the Core Data + CloudKit sync strategy, UUID-keyed data model, role-based access, and how the app replaced spreadsheets and messaging apps in a multi-user, multi-device environment.
The Problem: Warehouse and Office Coordination at Scale
A distribution business runs on coordination. Warehouse staff receive deliveries, pull orders, and track inventory. Office staff manage schedules, handle customer enquiries, and reconcile accounts. Both teams need access to the same data — and they are rarely in the same room, and almost never on the same network.
The client's situation before building The Company App: warehouse team on one group chat, office team on another, inventory tracked in a shared spreadsheet that was out of date by noon every day. Delivery confirmations came in over messaging apps. Scheduling conflicts were resolved by phone call.
This is not an unusual situation. It is how most small distribution businesses operate until the coordination overhead becomes the bottleneck.
Client Conditions
- Distribution business, 12–18 staff across two sites
- Mix of iPhone and iPad across warehouse and office teams
- Shared data accessed by multiple users simultaneously — no single source of truth
- Unreliable warehouse Wi-Fi — devices must read and write data without network dependency
- No existing backend infrastructure and no budget to build and maintain one
- Required: role differentiation between warehouse staff and office staff
These constraints eliminated several common approaches:
- Firebase / Supabase — cloud-dependent backends require a network connection for reads and writes
- Custom REST API — no budget for backend infrastructure; ongoing hosting and maintenance cost
- Web app — offline capability is a first-class requirement; a web app with service workers can approximate offline behaviour but with significant complexity
The correct solution is local-first persistence with CloudKit sync: Core Data as the on-device source of truth, NSPersistentCloudKitContainer for background synchronisation.
Architecture
Data Layer: Core Data with NSPersistentCloudKitContainer
NSPersistentCloudKitContainer is a drop-in replacement for NSPersistentContainer that adds automatic CloudKit synchronisation. The app writes to Core Data. CloudKit sync happens in the background. Reads always come from the local store — the app never blocks on CloudKit availability.
class DataController: ObservableObject {
let container: NSPersistentCloudKitContainer
init() {
container = NSPersistentCloudKitContainer(name: "CompanyModel")
container.persistentStoreDescriptions.first?.setOption(
true as NSNumber,
forKey: NSPersistentHistoryTrackingKey
)
container.persistentStoreDescriptions.first?.setOption(
true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey
)
container.loadPersistentStores { storeDescription, error in
if let error {
fatalError("Core Data store failed: \(error)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
automaticallyMergesChangesFromParent ensures that background CloudKit sync operations — which land in the background context — propagate to the view context without manual notification handling.
UUID Primary Keys for Conflict-Safe Merging
Every entity in the data model uses a UUID as its primary key. This is not optional for CloudKit sync — integer auto-increment keys produce conflicts when multiple devices create records simultaneously.
@Model
class DeliveryRecord {
var id: UUID
var referenceNumber: String
var status: DeliveryStatus
var assignedTo: UUID // Staff member UUID
var createdAt: Date
var updatedAt: Date
init(referenceNumber: String) {
self.id = UUID()
self.referenceNumber = referenceNumber
self.status = .pending
self.assignedTo = UUID() // Placeholder, assigned later
self.createdAt = Date()
self.updatedAt = Date()
}
}
The UUID key means that a record created by the warehouse team on an iPad and a record created by the office team on an iPhone cannot produce a primary key collision, regardless of whether those devices were online when the records were created.
Role-Based Access
Role is stored as a Core Data entity linked to the device's iCloud account identifier. This allows:
- The same iCloud account to have different roles in different company instances
- Role changes to propagate to all devices via CloudKit sync without requiring app reinstall
- View-layer access control based on the current user's role without a server round-trip
enum StaffRole: String, Codable {
case warehouse
case office
case admin
}
@Observable
class SessionManager {
var currentRole: StaffRole?
func resolveRole(for iCloudID: String) async {
let role = await roleRepository.fetchRole(for: iCloudID)
self.currentRole = role
}
}
SwiftUI with iPhone and iPad Adaptive Layout
The app is a single SwiftUI codebase. On iPhone, navigation is stack-based — a list view navigates to a detail view. On iPad, a NavigationSplitView presents the list and detail simultaneously in a two-column layout.
struct ContentView: View {
@State private var selectedItem: DeliveryRecord?
var body: some View {
NavigationSplitView {
DeliveryListView(selection: $selectedItem)
} detail: {
if let item = selectedItem {
DeliveryDetailView(record: item)
} else {
ContentUnavailableView(
"Select a delivery",
systemImage: "shippingbox"
)
}
}
}
}
NavigationSplitView collapses to a stack-based navigation automatically on iPhone. No device detection, no conditional layout code.
Results
Offline read/write: fully functional with no network connection
Sync latency: CloudKit changes propagate to other devices in 2–15 seconds when connectivity is available
Concurrent users: tested with 8 simultaneous active users, no merge conflicts or data loss in 6 months of production use
Device support: iPhone 13+, iPad (6th generation+), iOS 17+
Role-based access: three roles, configurable per-user via admin panel
Spreadsheet replacement: the shared inventory spreadsheet was retired on day 3 of deployment. The group chats are still used for informal communication but no longer carry operational data.
App Store first-submission approval: passed on first submission
Download and Explore
The Company App is available on the App Store for iPhone and iPad.
Download The Company App on the App Store →
The Company App product page →
If your business has a similar coordination challenge — multiple teams, mixed connectivity, shared operational data — this architecture scales to your requirements.