Bricks is a lightweight Swift library for building composable data loading and caching services. It provides a set of building blocks — entities and decorators — that chain together through a fluent dot-notation API to form complex data pipelines from simple, testable pieces.
- iOS 10.0+
- macOS 10.12+
- Swift 5+
pod 'Bricks', '~> 1.0'The library is built on two base protocols. Every component implements one of them.
Query — any asynchronous operation that produces a result:
public protocol Query {
associatedtype Result
func load(completion: @escaping (Result) -> Void)
}FailableQuery — extends Query for operations that can fail:
public protocol FailableQuery: Query
where Result == Swift.Result<Success, Failure>
{
associatedtype Success
associatedtype Failure: Error
}Each component exposes extension methods on these protocols, making it possible to build sophisticated pipelines by chaining calls:
cache
.expiring(validationPolicy: policy)
.fallback(networkLoader.map(with: mapper).store(into: cache))| Category | Component | API |
|---|---|---|
| Storage | InMemoryStorage |
.asQuery() |
SynchronousStorageAdapter |
.asAsyncStorage() |
|
ExpiringCache |
.expiring(validationPolicy:) |
|
| Transformation | Converter |
.convert(map:) |
FailableConverter |
.map(with:) |
|
| Persistence | StoringQuery |
.store(into:) |
| Error handling | Fallback |
.fallback(_:) |
SecondChance |
.secondChance(_:) |
|
| Concurrency | Synchronizer |
.synchronize(with:) |
DemultiplyingQuery |
.serial() |
|
| Observation | NotifyingQuery |
.notify(onSuccess:onFailure:) |
| Parallel loading | ParallelPriorityLoader |
.loadMandatory(mandatoryPriority:timeout:) |
| Pagination | Paginator |
load() / loadMore() |
| Commands | Command |
execute(_:completion:) |
Map a loaded value to another type:
let loader = networkLoader
.map(with: { dto in Article(dto: dto) })
loader.load { result in
switch result {
case .success(let article): display(article)
case .failure(let error): showError(error)
}
}Read from an in-memory cache first. On a miss or stale data, fetch from the network, transform the result, and repopulate the cache:
let cache = InMemoryStorage<[Article]>()
let asyncCache = cache.asAsyncStorage()
let loader = asyncCache
.expiring(validationPolicy: TimeIntervalValidationPolicy())
.fallback(
networkLoader
.map(with: { dtos in dtos.map(Article.init) })
.store(into: asyncCache)
)Pipeline steps:
- Read from
InMemoryStorage - Check timestamp against
TimeIntervalValidationPolicy - Cache valid → return immediately
- Cache expired → run the fallback branch:
- Fetch DTOs from the network
- Map to
Articleobjects - Save to cache for the next call
- Return the fresh data
Both decorators execute a secondary query when the primary fails. The difference is which error surfaces when both fail:
// On double failure — returns the secondary query's error
primary.fallback(secondary)
// On double failure — returns the primary query's error
primary.secondChance(secondary)Use .fallback() when you treat the secondary as the authoritative source.
Use .secondChance() when the primary error is the one that matters to the caller.
.serial() ensures that simultaneous load() calls result in only one underlying request. All callers waiting at the time the request completes receive the same result:
let loader = networkLoader.serial()
// These two concurrent calls produce a single network request
loader.load { result in /* caller A */ }
loader.load { result in /* caller B — gets the same result as A */ }Execute side effects on success or failure without modifying the query's result or altering the pipeline:
let loader = networkLoader
.notify(
onSuccess: { articles in Analytics.track("loaded", count: articles.count) },
onFailure: { error in Logger.error(error) }
)Run two independent queries in parallel and receive both results in a single callback:
userLoader
.synchronize(with: settingsLoader)
.load { userResult, settingsResult in
guard
case .success(let user) = userResult,
case .success(let settings) = settingsResult
else { return }
configure(user: user, settings: settings)
}Load multiple resources concurrently. Mark items as .required or .optional — the loader succeeds as soon as all required items complete. Optional items are included if they finish within the timeout:
struct SectionLoader: PriorityLoadingItem {
typealias Success = [Post]
typealias Failure = Error
let sectionId: String
let priority: ParallelPriority
func load(completion: @escaping (Result<[Post], Error>) -> Void) { ... }
}
let items = [
SectionLoader(sectionId: "main", priority: .required),
SectionLoader(sectionId: "recommended", priority: .optional),
SectionLoader(sectionId: "trending", priority: .optional)
].map { $0.eraseToAnyPriorityLoadingItem() }
ParallelPriorityLoader(items, mandatoryPriority: .required, timeout: 5.0)
.load { result in
switch result {
case .success(let sections): // [[Post]?] — nil for items that didn't complete in time
render(sections)
case .failure(.timeout):
showTimeout()
case .failure(.mandatoryFailed):
showError()
}
}Paginator manages page state and accumulates results across calls. Concurrent load() / loadMore() calls are automatically deduplicated:
let paginator = Paginator { page in
ArticlePageRequest(page: page) // returns a FailableQuery<[Article], Error>
}
// Load the first page
paginator.load { result in
switch result {
case .success(let page): display(page)
case .failure(let error): showError(error)
}
}
// Load the next page, appending to paginator.data
paginator.loadMore { result in ... }
// All accumulated items
let allArticles: [Article] = paginator.data
// Reset page counter and clear data
paginator.reset()Bricks is available under the MIT license.
