“Zeus gave man Pandora, a beautiful evil … and from her jar flowed every misfortune that haunts humanity, leaving only hope left inside.”
— Aeschylus
A powerful, type-safe caching library for Swift that provides multiple storage strategies with a unified API. Built with Swift Concurrency, Combine integration, and modern Swift best practices.
- Features
- Installation
- Quick Start
- Cache Types
- Type Declaration Options
- Advanced Usage
- Thread Safety
- Clean Architecture Example Usage
- License
✨ Multiple Storage Strategies
- Memory Cache: Fast in-memory storage with LRU eviction and optional TTL
- Disk Cache: Persistent file-based storage with actor isolation and optional TTL
- Hybrid Cache: Combines memory + disk with concurrent load deduplication
- UserDefaults Cache: Namespaced, type-safe storage with optional iCloud sync, global limits, and per-item size caps
- Lightweight: ~1.5MB, zero dependencies
🚀 Modern Swift Architecture
- Built on Swift Concurrency (
async/await) - Actor isolation for safe persistence without manual locks
- Generic, type-safe APIs
- Combine publishers for reactive data flow with simplified API
⚡ Performance
- LRU eviction in memory & disk
- Per-entry and global TTLs
- Concurrent load deduplication in HybridBox (
inflighttask pooling) - Namespace-based cache separation
Add Pandora to your project using Xcode or by adding it to your Package.swift:
dependencies: [
.package(url: "https://github.com/joshgallantt/Pandora.git", from: "3.2.0")
]import Pandora
// Memory cache — fast, in-memory only
let memoryBox: PandoraMemoryBox<String, User> = Pandora.Memory.box()
memoryBox.put(key: "user123", value: user)
let cachedUser = memoryBox.get("user123")
// Disk cache — persistent, actor-isolated
let diskBox: PandoraDiskBox<String, User> = Pandora.Disk.box(namespace: "users")
await diskBox.put(key: "user123", value: user)
let persistedUser = await diskBox.get("user123")
// Hybrid cache — memory first, disk fallback, async hydration
let hybridBox: PandoraHybridBox<String, User> = Pandora.Hybrid.box(namespace: "users")
hybridBox.put(key: "user123", value: user)
let hybridUser = await hybridBox.get("user123")
// UserDefaults cache — type-safe key-value store with optional iCloud sync
let defaultsBox: PandoraUserDefaultsBox<User> = Pandora.UserDefaults.box(
namespace: "user_defaults",
iCloudBacked: true // default: true
)
defaultsBox.put(key: "user123", value: user)
let defaultsUser = await defaultsBox.get("user123")Perfect for frequently accessed data that doesn't need persistence.
let box: PandoraMemoryBox<String, Data> = Pandora.Memory.box(
maxSize: 1000,
expiresAfter: 3600
)
box.put(key: "thumb", value: imageData)
let data = box.get("thumb")
box.publisher(for: "thumb")
.sink { /* react to updates */ }
.store(in: &cancellables)Actor-isolated persistent storage for data that survives app restarts.
let box: PandoraDiskBox<String, UserProfile> = Pandora.Disk.box(
namespace: "profiles",
maxSize: 10000,
expiresAfter: 86400
)
await box.put(key: "p1", value: userProfile)
let profile = await box.get("p1")Combines memory and disk storage for optimal performance and persistence.
let box: PandoraHybridBox<String, APIResponse> = Pandora.Hybrid.box(
namespace: "api_cache",
memoryMaxSize: 500,
memoryExpiresAfter: 300,
diskMaxSize: 5000,
diskExpiresAfter: 3600
)
box.put(key: "resp", value: response)
let cached = await box.get("resp")
box.publisher(for: "resp")
.sink { updateUI($0) }
.store(in: &cancellables)Type-safe UserDefaults storage with namespace isolation,
optional iCloud synchronization.
let settingsBox: PandoraUserDefaultsBox<String> =
Pandora.UserDefaults.box(namespace: "settings")
settingsBox.put(key: "username", value: "john")
let username = await settingsBox.get("username")Warning
- Max 1024 items across all
UserDefaultsBoxinstances - Max 1KB per stored value
- Enforced globally`
Tip
To enable iCloud synchronization, you must add the iCloud capability in your Xcode target’s Signing & Capabilities tab, and under iCloud services check Key-Value storage. Without this, iCloud-backed UserDefaults (via NSUbiquitousKeyValueStore) will not work.
Pandora boxes are generic over their key and value types (except UserDefaults, which is generic only over the value type).
There are three ways to specify those types depending on context.
// Memory, Disk, and Hybrid require both Key and Value types
let memoryBox: PandoraMemoryBox<String, User> = Pandora.Memory.box()
let diskBox: PandoraDiskBox<String, User> = Pandora.Disk.box(namespace: "users")
let hybridBox: PandoraHybridBox<String, User> = Pandora.Hybrid.box(namespace: "users")
// UserDefaults requires only Value type
let defaultsBox: PandoraUserDefaultsBox<User> = Pandora.UserDefaults.box(namespace: "users")let memoryBox = Pandora.Memory.box() as PandoraMemoryBox<String, User>
let diskBox = Pandora.Disk.box(namespace: "users") as PandoraDiskBox<String, User>
let hybridBox = Pandora.Hybrid.box(namespace: "users") as PandoraHybridBox<String, User>
let defaultsBox = Pandora.UserDefaults.box(namespace: "users") as PandoraUserDefaultsBox<User>Useful when Swift can’t infer types or when constructing dynamically (e.g., in generic or factory contexts).
// Memory, Disk, Hybrid
let memoryBox = Pandora.Memory.box(
keyType: String.self,
valueType: User.self
)
let diskBox = Pandora.Disk.box(
namespace: "users",
keyType: String.self,
valueType: User.self
)
let hybridBox = Pandora.Hybrid.box(
namespace: "users",
keyType: String.self,
valueType: User.self
)
// UserDefaults only requires Value type
let defaultsBox = Pandora.UserDefaults.box(
namespace: "users",
valueType: User.self
)Tip
Explicit type parameters are especially useful inside generic or factory contexts where the return type isn’t obvious.
let cache: PandoraMemoryBox<String, Data> = Pandora.Memory.box()
// Store with custom TTL
cache.put(
key: "short_lived_data",
value: data,
expiresAfter: 60 // 1 minute
)
// Store without expiration (overrides global TTL)
cache.put(
key: "permanent_data",
value: data,
expiresAfter: nil
)let cache: PandoraMemoryBox<String, User> = Pandora.Memory.box()
// Observe specific keys (emits current value immediately)
cache.publisher(for: "current_user")
.compactMap { $0 } // Filter out nil values
.sink { user in
print("User updated: \(user.name)")
}
.store(in: &cancellables)
// Observe changes (current value is always emitted)
cache.publisher(for: "current_user")
.compactMap { $0 }
.sink { user in
print("User changed: \(user.name)")
}
.store(in: &cancellables)
// Chain multiple cache operations
cache.publisher(for: "user_id")
.compactMap { $0 }
.flatMap { userId in
fetchUserDetails(userId)
}
.sink { userDetails in
// Handle user details
}
.store(in: &cancellables)
// Skip initial value if you only want future updates
cache.publisher(for: "current_user")
.dropFirst() // Skip the immediate current value emission
.compactMap { $0 }
.sink { user in
print("User changed: \(user.name)")
}
.store(in: &cancellables)All Pandora publishers emit the current value immediately upon subscription, followed by any future changes:
publisher(for: "key")- Emits current value immediately, then future changes- Use
.dropFirst()if you only want to observe future changes, not the current value
// Clear specific cache instances
memoryCache.clear() // Synchronous for MemoryBox
await diskCache.clear() // Asynchronous for DiskBox
await hybridCache.clear() // Asynchronous for HybridBox
await userDefaultsCache.clear() // Asynchronous for UserDefaultsBox
// Clear specific namespaces
Pandora.clearUserDefaults(for: "my_settings") // Clear specific UserDefaults namespace
Pandora.clearDiskData(for: "my_cache") // Clear specific disk namespace
// Clear all data
Pandora.clearAllUserDefaults() // Clear all Pandora UserDefaults data
Pandora.clearAllDiskData() // Clear all Pandora disk caches
Pandora.deleteAllLocalStorage() // Nuclear option - clear everythingAll Pandora cache types are designed for concurrent access:
- MemoryBox: Lock-based thread safety
- DiskBox: Actor-isolated
- HybridBox: Locks for memory + inflight tracking, actor-isolated disk
- UserDefaultsBox: Locks + optional iCloud sync
1. Create your repository and initialise the Cache
import Pandora
import Combine
final class WishlistRepository {
private let cache: PandoraMemoryBox<String, Set<String>>
private let service: WishlistService
private let wishlistKey = "wishlist"
init(service: WishlistService) {
self.service = service
self.cache = Pandora.Memory.box(
maxSize: 1000,
expiresAfter: 3600 // 1 hour TTL
)
}
func observeIsWishlisted(productID: String) -> AnyPublisher<Bool, Never> {
cache.publisher(for: wishlistKey)
.map { ids in ids?.contains(productID) ?? false }
.eraseToAnyPublisher()
}
func addToWishlist(productID: String) async throws {
let updatedIDs = try await service.addProduct(productID: productID)
cache.put(key: wishlistKey, value: Set(updatedIDs))
}
func removeFromWishlist(productID: String) async throws {
let updatedIDs = try await service.removeProduct(productID: productID)
cache.put(key: wishlistKey, value: Set(updatedIDs))
}
}2. Use Cases use the Cache
struct ObserveProductInWishlistUseCase {
private let repository: WishlistRepository
init(repository: WishlistRepository) { self.repository = repository }
func execute(productID: String) -> AnyPublisher<Bool, Never> {
repository.observeIsWishlisted(productID: productID)
.removeDuplicates() // Ensures only changes are delivered to ViewModel
.eraseToAnyPublisher()
}
}
struct AddProductToWishlistUseCase {
private let repository: WishlistRepository
init(repository: WishlistRepository) { self.repository = repository }
func execute(productID: String) async throws {
try await repository.addToWishlist(productID: productID)
}
}
struct RemoveProductFromWishlistUseCase {
private let repository: WishlistRepository
init(repository: WishlistRepository) { self.repository = repository }
func execute(productID: String) async throws {
try await repository.removeFromWishlist(productID: productID)
}
}3. ViewModels use the Use Cases
import Combine
import Foundation
@MainActor
final class WishlistButtonViewModel: ObservableObject {
@Published private(set) var isWishlisted: Bool = false
private let productID: String
private let observeProductInWishlist: ObserveProductInWishlistUseCase
private let addProductToWishlist: AddProductToWishlistUseCase
private let removeProductFromWishlist: RemoveProductFromWishlistUseCase
private var cancellables = Set<AnyCancellable>()
init(
productID: String,
observeProductInWishlist: ObserveProductInWishlistUseCase,
addProductToWishlist: AddProductToWishlistUseCase,
removeProductFromWishlist: RemoveProductFromWishlistUseCase
) {
self.productID = productID
self.observeProductInWishlist = observeProductInWishlist
self.addProductToWishlist = addProductToWishlist
self.removeProductFromWishlist = removeProductFromWishlist
observeWishlistState()
}
private func observeWishlistState() {
observeProductInWishlist.execute(productID: productID)
.receive(on: DispatchQueue.main)
.assign(to: &$isWishlisted)
}
func toggleWishlist() {
let newValue = !isWishlisted
isWishlisted = newValue
Task(priority: .userInitiated) { [self, newValue] in
do {
if newValue {
try await addProductToWishlist.execute(productID: productID)
} else {
try await removeProductFromWishlist.execute(productID: productID)
}
} catch {
await MainActor.run {
isWishlisted = !newValue
}
}
}
}
}This project is licensed under the MIT License - see the LICENSE file for details.
Created with ❤️ by Josh Gallant - for the Swift community.