# Share Extensions in Practice – Part 3: App Groups & Core Data for Shared Storage
Table of Contents
This is part 3 of a 4-part series on building robust iOS Share Extensions:
- Part 1: When Duplication Beats Shared Frameworks - Pragmatic API architecture
- Part 2: Secure Keychain Sharing - Authentication tokens and sensitive data
- Part 3: App Groups & Core Data (this article) - Shared storage and synchronization
- Part 4: SwiftUI Integration - Modern UI with hosting controllers (coming soon)
While the Keychain handles sensitive data like authentication tokens, what about everything else? Content shared from other apps, metadata, offline queues, user preferences – all of this needs a robust shared storage solution.
This is where App Groups come in. They provide a shared container accessible to both your main app and Share Extension. Combined with Core Data, you get a powerful foundation for offline persistence and seamless data synchronization.
Here’s the strategy: The Share Extension quickly saves shared content locally for instant user feedback. The main app handles the heavy lifting – syncing to servers, processing metadata, and providing full management UI. Both work with the same data store, but with clear responsibilities.
Setting up App GroupsLink to heading
App Groups create a shared file system container that both your main app and Share Extension can access. This is the foundation for all shared data between the two processes.
Configure App Group CapabilitiesLink to heading
First, enable App Groups in both your main app and Share Extension targets. This creates a shared container that both processes can access:
In Xcode:
- Select your main app target → Signing & Capabilities
- Click + Capability → App Groups
- Click + and add:
group.com.yourcompany.yourapp.shared
- Repeat for your Share Extension target with the identical group identifier
Important: Both targets must use exactly the same App Group identifier. Even a single character difference will break sharing.
Access the Shared ContainerLink to heading
With App Groups configured, you can access the shared file system:
import Foundation
class SharedContainer { static let groupIdentifier = "group.com.yourcompany.yourapp.shared"
static var sharedContainerURL: URL? { return FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: groupIdentifier ) }
static var documentsURL: URL? { return sharedContainerURL?.appendingPathComponent("Documents") }
static var coreDataURL: URL? { return sharedContainerURL?.appendingPathComponent("CoreDataStore.sqlite") }}
This gives both targets access to the same file system location where you can store Core Data databases, temporary files, or any other shared resources.
Core Data Stack for App GroupsLink to heading
Once you have App Groups configured, the next step is setting up Core Data to use the shared container. This involves placing your persistent store in the shared location and handling any existing data migration.
Migrating Existing Databases to App GroupsLink to heading
If you’re adding App Groups to an existing app with Core Data, you’ll need to migrate the existing database to the shared container. This is a common scenario when adding Share Extensions to established apps.
The challenge: Your existing Core Data store lives in the app’s private Documents directory, but the Share Extension can’t access it. You need to move it to the shared container while preserving all user data.
Here’s a simplified migration approach that you can integrate into your Core Data setup:
// Add to your SharedCoreDataManagerprivate func migrateStoreToAppGroupIfNeeded(targetURL: URL) { let fileManager = FileManager.default
// Check if store already exists in app group if fileManager.fileExists(atPath: targetURL.path) { return }
// Look for existing database in Documents directory guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
let oldStoreURL = documentsURL.appendingPathComponent("SharedDataModel.sqlite")
if fileManager.fileExists(atPath: oldStoreURL.path) { do { // Create app group directory let appGroupDirectory = targetURL.deletingLastPathComponent() try fileManager.createDirectory(at: appGroupDirectory, withIntermediateDirectories: true)
// Copy database files try fileManager.copyItem(at: oldStoreURL, to: targetURL)
// Copy WAL and SHM files if they exist let walURL = oldStoreURL.appendingPathExtension("wal") let shmURL = oldStoreURL.appendingPathExtension("shm")
if fileManager.fileExists(atPath: walURL.path) { try fileManager.copyItem(at: walURL, to: targetURL.appendingPathExtension("wal")) }
if fileManager.fileExists(atPath: shmURL.path) { try fileManager.copyItem(at: shmURL, to: targetURL.appendingPathExtension("shm")) }
print("Database migrated to App Group successfully")
} catch { print("Migration failed: \(error)") } }}
Key points:
- Migration happens automatically on first launch
- Copies all SQLite files (.sqlite, .wal, .shm)
- Graceful error handling that doesn’t crash the app
Shared Core Data ManagerLink to heading
Create a Core Data manager that both targets can use. The key is placing the persistent store in the shared container:
import CoreData
class SharedCoreDataManager { static let shared = SharedCoreDataManager() private let groupIdentifier = "group.com.yourcompany.yourapp.shared"
private init() {}
lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "SharedDataModel")
// Use App Group container for shared access if let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier)? .appendingPathComponent("SharedDataModel.sqlite") {
// Migrate existing database if needed migrateStoreToAppGroupIfNeeded(targetURL: storeURL)
let storeDescription = NSPersistentStoreDescription(url: storeURL) container.persistentStoreDescriptions = [storeDescription] }
container.loadPersistentStores { _, error in if let error = error { print("Core Data failed to load: \(error)") } }
// Merge policy for conflicts (important when both app and extension access simultaneously) container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy return container }()
var context: NSManagedObjectContext { return persistentContainer.viewContext }
func save() { try? context.save() }}
Core Data Model Target MembershipLink to heading
⚠️ Critical step often forgotten: Your .xcdatamodeld
file must be included in both targets. This is the most common cause of Share Extension crashes when using Core Data.
In Xcode:
- Select your
DataModel.xcdatamodeld
file - In the File Inspector, check Target Membership for both:
- ✅ Your main app target
- ✅ Your Share Extension target
Without this step: Your Share Extension will crash at runtime when trying to access Core Data entities, with confusing error messages about unknown entity types.
Entity Design Best PracticesLink to heading
When designing entities for App Group sharing:
Essential attributes for shared entities:
- UUID primary key: Prevents ID conflicts between targets
- Timestamps: For conflict resolution (
createdAt
,updatedAt
) - Sync status: Track what needs server upload (
pending
,synced
,failed
) - Simple data types: Strings, Dates, Booleans work best across targets
Example BookmarkEntity
with these attributes:
id: UUID
(primary key)url: String
title: String
createdAt: Date
syncStatus: String
Using Core Data in the Share ExtensionLink to heading
With the shared Core Data stack in place, your Share Extension can now save data directly to the same store your main app uses. The key is keeping operations simple and fast for a responsive user experience.
Local Storage StrategyLink to heading
Share Extensions can make API calls directly, but local storage provides a robust fallback and better user experience in many scenarios.
When to use local storage:
- Server unreachable - Network issues, server downtime, maintenance
- Instant feedback - Users get immediate confirmation without waiting for network
- Offline functionality - Works in airplane mode or poor connectivity
- Queue mechanism - Store items locally, sync later when convenient
Simple Wrapper ApproachLink to heading
Use a simple wrapper class that handles the Core Data operations:
class OfflineBookmarkManager { static let shared = OfflineBookmarkManager() private init() {}
func saveBookmark(url: String, title: String) -> Bool { let context = SharedCoreDataManager.shared.context
do { let entity = BookmarkEntity(context: context) entity.id = UUID() entity.url = url entity.title = title entity.syncStatus = "pending"
try context.save() return true } catch { print("Failed to save bookmark: \(error)") return false } }}
// In your Share Extensionclass ShareViewController: SLComposeServiceViewController { override func didSelectPost() { guard let url = extractSharedURL() else { extensionContext?.completeRequest(returningItems: [], completionHandler: nil) return }
// Save locally immediately let success = OfflineBookmarkManager.shared.saveBookmark( url: url.absoluteString, title: contentText ?? "" )
// Complete extension regardless of save result extensionContext?.completeRequest(returningItems: [], completionHandler: nil) }}
This wrapper provides a fallback when API calls fail or when you want to queue items for later processing. You can also use local storage as the primary approach - many apps store shared content locally and sync later, or don’t sync at all depending on the use case.
Performance note: For saving many items at once (like bulk imports), consider using NSBatchInsertRequest
instead of individual entity creation for better performance.
Synchronization in the Main AppLink to heading
While the Share Extension handles immediate data capture, the main app takes responsibility for processing, organizing, and syncing that data to external services. This separation keeps the extension lightweight while providing full functionality in the main app.
Sync Strategy with User ControlLink to heading
If your use case requires server sync, consider giving users control over when it happens. This provides better battery life and allows users to sync on their own terms.
Find the right integration point - Main menu, tab view, or sidebar are good entry points where users naturally expect sync functionality. This works consistently across all device types.
@Observableclass OfflineBookmarksViewModel { var pendingCount: Int = 0 var isSyncing: Bool = false
private let getPendingCountUseCase = GetPendingItemsCountUseCase() private let syncPendingItemsUseCase = SyncPendingItemsUseCase()
init() { refreshPendingCount() }
func refreshPendingCount() { pendingCount = getPendingCountUseCase.execute() }
// Simplified sync flow - adapt to your specific needs func syncPendingItems() async { guard pendingCount > 0 else { return }
isSyncing = true await syncPendingItemsUseCase.execute() refreshPendingCount() isSyncing = false }
}
// In your main view (TabView, NavigationView, etc.)struct MainView: View { @State private var syncViewModel = OfflineBookmarksViewModel()
var body: some View { TabView { // Your content tabs
if syncViewModel.pendingCount > 0 { SyncView() .tabItem { Label("Sync", systemImage: "arrow.triangle.2.circlepath") } .badge(syncViewModel.pendingCount) } } }}
Benefits of user-controlled sync:
- Battery efficiency - No background network requests
- User awareness - Clear indication when data needs syncing
- Network control - Users can choose when to use data/Wi-Fi
- Transparency - Users see exactly what’s being synced
Data Flow OverviewLink to heading
Now that we’ve covered all the implementation details, here’s how everything works together:
The same App Group pattern works for any type of extension - photo editing extensions, action extensions, or custom keyboard extensions can all share data through the same Core Data store.
ConclusionLink to heading
App Groups and Core Data provide a solid foundation for sharing data between your main app and Share Extension. The shared container enables seamless data flow, while Core Data ensures reliable persistence and conflict resolution.
Key takeaways:
- Use App Groups for shared file system access
- Place Core Data store in the shared container and migrate existing data if needed
- Keep Share Extension saves fast and simple
- Let the main app handle complex sync operations
The beauty of this approach is clear separation of concerns: the Share Extension handles immediate user needs while the main app manages the complex synchronization logic. Users get instant feedback, and you get robust data management.
Now that we’ve covered shared storage and synchronization, the next article will explore how to modernize your Share Extension’s user interface with SwiftUI. This is particularly interesting for creating more interactive and visually appealing extensions while maintaining the robust data architecture we’ve built.
Next up: SwiftUI integration for modern Share Extension UI with hosting controllers (coming soon).
This “Share Extensions in Practice” series shows you step by step how to develop stable, secure, and user-friendly Share Extensions for iOS.