Configuration
Archer provides a flexible configuration system through Configuration and Settings that controls caching behavior, error fallback strategies, and DSL execution context.
Overview
The configuration system consists of two main components:
Settings- Interface defining behavior settings (fallback strategies, cache management)Configuration- Extends Settings and provides DSL functions and repository strategies
Default Configuration
The simplest way to use Archer is with the default configuration:
import com.m2f.archer.configuration.Configuration
// Default configuration with standard settings
val config = Configuration.Default
Default Settings
The default configuration includes:
// Failures that trigger fallback from main to store
mainFallbacks = { failure ->
failure is DataNotFound ||
failure is Invalid ||
failure is NotModified ||
failure is NoConnection ||
failure is ServerFailure ||
failure is Redirect ||
failure is UnhandledNetworkFailure
}
// Failures that trigger fallback from store to main
storageFallbacks = { failure ->
failure is DataNotFound ||
failure is Invalid
}
// Cache is enabled by default
ignoreCache = false
Cache Implementation and Testing
Understanding the Default Cache
The default configuration relies on MemoizedExpirationCache, which is a platform-sensitive cache implementation that uses databases under the hood to keep records of expiration times and store metadata. This cache implementation:
- Uses SQLDelight to persist cache expiration metadata across application sessions
- Requires database drivers that are platform-specific (Android, iOS, JVM, etc.)
- Stores cache metadata including keys, expiration times, and creation timestamps
- Provides thread-safe access through mutex locks
// Default cache implementation (from Settings.Default)
override val cache: CacheDataSource<CacheMetaInformation, Instant> by lazy {
MemoizedExpirationCache()
}
The MemoizedExpirationCache implementation can be found in archer-core/src/commonMain/kotlin/com/m2f/archer/crud/cache/memcache/MemoizedExpirationCache.kt:22 and requires a database connection to function properly.
Testing Considerations
When writing tests, the default MemoizedExpirationCache can create several challenges:
- Database Setup Complexity - Requires setting up database drivers and schemas for testing
- Platform Dependencies - May not work consistently across all test environments
- Test Isolation - Shared database state between tests can cause failures
- Performance - Database operations can slow down unit tests
For testing, it is strongly recommended to create a custom testing configuration that uses an in-memory cache instead of the database-backed implementation.
Testing Configuration Examples
Archer provides testing configurations that demonstrate how to create your own custom configurations for tests. These examples are located in archer-core/src/commonTest/kotlin/com/m2f/archer/crud/cache/configuration/Configuration.kt:15.
Simple In-Memory Testing Configuration
The simplest approach is to replace the cache with an InMemoryDataSource:
import com.m2f.archer.configuration.Settings
import com.m2f.archer.datasource.InMemoryDataSource
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlin.time.Instant
// Simple in-memory cache configuration for testing
val inMemoryCacheConfiguration: (scheduler: TestCoroutineScheduler) -> Settings = { scheduler ->
object : Settings by Settings.Default {
// Replace database cache with in-memory implementation
override val cache = InMemoryDataSource()
// Use test scheduler for time control
override fun getCurrentTime(): Instant =
Instant.fromEpochMilliseconds(scheduler.currentTime)
}
}
// Usage in tests - use scoping functions
@Test
fun testUserRepository() = runTest {
val testSettings = inMemoryCacheConfiguration(testScheduler)
// Use settings.configuration extension property and with() for scoping
with(testSettings.configuration) {
ice {
// Your test code here
userRepository.get(MainSync, userId)
}
}
}
This configuration:
- Uses
InMemoryDataSourcewhich stores data in memory without any database - Delegates all other settings to
Settings.Default - Provides time control through the test scheduler for testing cache expiration
Testing Configuration with Fake Database
If you need to test database-specific cache behavior, you can use a fake database. This is useful when testing the actual MemoizedExpirationCache implementation with database persistence in an isolated test environment.
First, create a fake database repository for your tests:
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import com.m2f.archer.ExpirationRegistryQueries
import com.m2f.archer.crud.GetRepository
import com.m2f.archer.crud.cache.CacheExpiration.Never
import com.m2f.archer.crud.cache.cache
import com.m2f.archer.crud.getDataSource
import com.m2f.archer.crud.operation.StoreSync
import com.m2f.archer.sqldelight.CacheExpirationDatabase
// Platform-specific fake database driver factory
// (JVM example shown - use appropriate driver for your platform)
object FakeDatabaseDriverFactory {
suspend fun createDriver(schema: SqlSchema<*>): SqlDriver =
JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY).also {
schema.create(it)
}
}
// Create a fake queries repository for testing
val fakeQueriesRepo: GetRepository<Unit, ExpirationRegistryQueries>
get() = getDataSource<Unit, ExpirationRegistryQueries> {
CacheExpirationDatabase(
FakeDatabaseDriverFactory.createDriver(CacheExpirationDatabase.Schema)
).expirationRegistryQueries
}.cache(expiration = Never).create(StoreSync)
Then use it in your test configuration:
import com.m2f.archer.crud.cache.memcache.MemoizedExpirationCache
// Configuration with fake database for testing MemoizedExpirationCache
val testConfiguration: (scheduler: TestCoroutineScheduler) -> Settings = { scheduler ->
object : Settings by Settings.Default {
// Use MemoizedExpirationCache with a fake database
override val cache = MemoizedExpirationCache(
repo = fakeQueriesRepo // Fake in-memory database repository
)
override fun getCurrentTime(): Instant =
Instant.fromEpochMilliseconds(scheduler.currentTime)
}
}
// Usage in tests
@Test
fun testCacheWithDatabase() = runTest {
val testSettings = testConfiguration(testScheduler)
with(testSettings.configuration) {
ice {
// This uses MemoizedExpirationCache backed by in-memory SQLite
userRepository.get(MainSync, userId)
}
}
}
Note: For most tests, the simpler InMemoryDataSource approach is preferred. Only use a fake database when you specifically need to test database-backed cache behavior.
Creating Your Own Testing Configuration
To create your own testing configuration:
-
Choose your cache implementation:
InMemoryDataSource()- Simple, fast, no persistence (recommended for most tests)MemoizedExpirationCache(repo = fakeQueriesRepo)- Database-backed for integration tests
-
Control time for expiration testing:
override fun getCurrentTime(): Instant =
Instant.fromEpochMilliseconds(testScheduler.currentTime) -
Customize fallback behavior if needed:
override val mainFallbacks = { _: Failure -> false } // No fallbacks in tests
override val storageFallbacks = { _: Failure -> false }
Example: Complete Test Configuration
Here's a complete example of a custom test configuration:
import com.m2f.archer.configuration.Settings
import com.m2f.archer.configuration.configuration
import com.m2f.archer.datasource.InMemoryDataSource
import com.m2f.archer.failure.Failure
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlin.time.Instant
object TestConfiguration {
fun create(scheduler: TestCoroutineScheduler): Settings =
object : Settings {
// Use in-memory cache for fast, isolated tests
override val cache = InMemoryDataSource()
// Keep default fallbacks - they're usually correct
// Only override if you need specific test behavior
override val mainFallbacks = Settings.Default.mainFallbacks
override val storageFallbacks = Settings.Default.storageFallbacks
// Cache enabled for expiration testing
override val ignoreCache = false
// Controlled time for deterministic cache expiration
override fun getCurrentTime(): Instant =
Instant.fromEpochMilliseconds(scheduler.currentTime)
}
}
// Usage in tests - use settings.configuration and scoping
@Test
fun testCacheExpiration() = runTest {
val testSettings = TestConfiguration.create(testScheduler)
// Use with() for scoping - configuration created at the top of the chain
with(testSettings.configuration) {
ice {
// Test cache behavior with controlled time
val result = repository.get(MainSync, userId)
// Advance time to test expiration
testScheduler.advanceTimeBy(6.minutes)
// Cache should be expired now
val refreshed = repository.get(MainSync, userId)
}
}
}
Best Practices for Testing
- Always use in-memory cache for unit tests - Faster and more reliable than database-backed cache
- Use test scheduler - Enables controlled time advancement for cache expiration testing
- Keep default fallbacks - They handle common error scenarios correctly. Only override for specific test needs
- Create reusable test configurations - Define once, use across all tests
- Use
Configuration.ignoreCache()- When you want to bypass cache entirely in specific tests - Use scoping functions - Always use
with(settings.configuration) { ice { ... } }pattern instead of creating Configuration instances everywhere
Operations and Fallback Strategies
Understanding operations and how fallbacks work with them is crucial to using Archer effectively. This section explains the relationship between operations, repository strategies, and fallback configurations.
The Four Operations
Archer provides four operation types that control how data flows between your main data source (typically a remote API) and your store (typically local cache/database):
import com.m2f.archer.crud.operation.Main
import com.m2f.archer.crud.operation.Store
import com.m2f.archer.crud.operation.MainSync
import com.m2f.archer.crud.operation.StoreSync
Main
Fetches data only from the main data source (usually remote API). No caching, no fallbacks.
with(Configuration.Default) {
ice {
// Only calls the API, doesn't touch the cache
userRepository.get(Main, userId)
}
}
Use when:
- You need guaranteed fresh data
- You're posting/updating data to a server
- Cache should be bypassed completely
Behavior:
- Calls main data source
- Returns result or raises failure
- No fallback to store
- Does not update cache
Store
Fetches data only from the store (local cache/database). No network calls, no fallbacks.
with(Configuration.Default) {
ice {
// Only reads from cache, never calls the API
userRepository.get(Store, userId)
}
}
Use when:
- You want offline-first behavior
- You know data is already cached
- You want to display cached data while loading fresh data separately
Behavior:
- Calls store data source
- Returns cached result or raises failure (e.g., DataNotFound)
- No fallback to main
- No network calls
MainSync
The most commonly used operation. Tries main first, then falls back to store if configured fallback conditions are met, and syncs successful main responses to the store.
with(Configuration.Default) {
ice {
// Tries API first, falls back to cache on network errors
userRepository.get(MainSync, userId)
}
}
Use when:
- You want fresh data with offline fallback
- This is the default for most use cases
- You want automatic cache updates on successful fetches
Behavior:
- Calls main data source
- On success: Writes response to store, returns data
- On failure: Checks
mainFallbacksfunction- If
mainFallbacks(failure)returnstrue: Falls back to store - If
mainFallbacks(failure)returnsfalse: Raises the failure
- If
Implementation (from archer-core/src/commonMain/kotlin/com/m2f/archer/repository/MainSyncRepository.kt:18):
// Simplified version showing the logic
override suspend fun ArcherRaise.invoke(q: Get<K>): A =
archerRecover(
block = {
// Try to get from main and store it
storeDataSource.put(q.key, mainDataSource.get(q.key))
},
recover = { failure ->
if (fallbackChecks(failure)) {
// Fallback to store if configured
storeDataSource.get(q.key)
} else {
raise(failure)
}
}
)
StoreSync
Tries store first, then falls back to main if configured fallback conditions are met. When falling back to main, it automatically syncs the data back to store (by calling MainSync internally).
with(Configuration.Default) {
ice {
// Tries cache first, falls back to API if not found
userRepository.get(StoreSync, userId)
}
}
Use when:
- You want offline-first with automatic refresh
- You want to minimize network calls
- Cache-first is acceptable for your use case
Behavior:
- Calls store data source
- On success: Returns cached data
- On failure: Checks
storageFallbacksfunction- If
storageFallbacks(failure)returnstrue: Falls back to MainSync (which fetches from main and updates store) - If
storageFallbacks(failure)returnsfalse: Raises the failure
- If
Implementation (from archer-core/src/commonMain/kotlin/com/m2f/archer/repository/StoreSyncRepository.kt:19):
// Simplified version showing the logic
override suspend fun ArcherRaise.invoke(q: Get<K>): A =
archerRecover(
block = {
storeDataSource.get(q.key)
},
recover = { failure ->
if (fallbackChecks(failure)) {
// Fallback to MainSync, which updates the store
MainSyncRepository(mainDataSource, storeDataSource, mainFallbackChecks).get(q.key)
} else {
raise(failure)
}
}
)
Understanding Fallbacks
Fallbacks control when to switch from one data source to another when an operation fails. Archer provides two fallback functions in Settings:
mainFallbacks
Controls when MainSync falls back from main to store.
// Default configuration (from Settings.Default)
override val mainFallbacks = { failure: Failure ->
failure is DataNotFound ||
failure is Invalid ||
failure is NotModified ||
failure is NoConnection ||
failure is ServerFailure ||
failure is Redirect ||
failure is UnhandledNetworkFailure
}
This means: When using MainSync, if the API call fails with any network-related error, Archer will automatically try to return cached data instead.
Example:
with(Configuration.Default) {
ice {
// If API fails with NoConnection, automatically returns cached data
userRepository.get(MainSync, userId)
}
}
storageFallbacks
Controls when StoreSync falls back from store to main.
// Default configuration (from Settings.Default)
override val storageFallbacks = { failure: Failure ->
failure is DataNotFound ||
failure is Invalid
}
This means: When using StoreSync, if the cache is empty (DataNotFound) or expired (Invalid), Archer will automatically fetch from the API and update the cache.
Example:
with(Configuration.Default) {
ice {
// If cache is empty, automatically fetches from API and caches it
userRepository.get(StoreSync, userId)
}
}
When Operations Use Fallbacks
This table shows which operations use which fallback functions:
| Operation | Uses mainFallbacks | Uses storageFallbacks | Syncs to Store |
|---|---|---|---|
Main | ❌ No | ❌ No | ❌ No |
Store | ❌ No | ❌ No | ❌ No |
MainSync | ✅ Yes | ❌ No | ✅ Yes (on success) |
StoreSync | ✅ Yes (via MainSync) | ✅ Yes | ✅ Yes (when falling back) |
How Repository Strategies Work with Operations
When you create a repository using cacheStrategy, Archer creates different repository implementations based on the operation:
// From Configuration.kt:28
fun <K, A> cacheStrategy(
mainDataSource: GetDataSource<K, A>,
storeDataSource: StoreDataSource<K, A>,
): GetRepositoryStrategy<K, A> = GetRepositoryStrategy { operation ->
when (operation) {
is Main -> mainDataSource.toRepository()
is Store -> storeDataSource.toRepository()
is MainSync -> MainSyncRepository(mainDataSource, storeDataSource, mainFallbacks)
is StoreSync -> StoreSyncRepository(storeDataSource, mainDataSource, storageFallbacks, mainFallbacks)
}
}
This means: The same repository can behave differently based on which operation you pass:
val userRepository = with(Configuration.Default) {
apiDataSource.cacheWith(dbDataSource).expiresIn(5.minutes)
}
with(Configuration.Default) {
ice {
// Four different behaviors with the same repository
val fresh = userRepository.get(Main, userId) // API only
val cached = userRepository.get(Store, userId) // Cache only
val freshWithFallback = userRepository.get(MainSync, userId) // API → Cache fallback
val cacheFirst = userRepository.get(StoreSync, userId) // Cache → API fallback
}
}
Why You Should Rarely Override Fallbacks
The default fallback configurations are designed to handle the most common scenarios correctly:
✅ Default mainFallbacks handles:
- Network errors (no connection, server failures)
- Empty responses (data not found)
- Invalid/expired cache markers
- HTTP redirects
✅ Default storageFallbacks handles:
- Cache miss (data not found)
- Expired cache (invalid)
These defaults work correctly for ~95% of use cases. Only override fallbacks when you have specific requirements:
// ❌ Usually NOT needed - defaults are fine
object StrictSettings : Settings by Settings.Default {
override val mainFallbacks = { _: Failure -> false } // Never fall back
override val storageFallbacks = { _: Failure -> false }
}
// ✅ Rare valid case - custom error handling
object CustomSettings : Settings by Settings.Default {
override val mainFallbacks = { failure: Failure ->
// Only fall back on network errors, not on 404s
failure is NoConnection || failure is ServerFailure
}
}
Complete Example: Operations in Practice
Here's a complete example showing how to use operations effectively:
import com.m2f.archer.configuration.Configuration
import com.m2f.archer.crud.operation.*
import kotlin.time.Duration.Companion.minutes
// Setup: Create repository at the top level
val userRepository = with(Configuration.Default) {
apiDataSource.cacheWith(dbDataSource).expiresIn(5.minutes)
}
// Use different operations for different scenarios
class UserViewModel {
// Load user with offline support
suspend fun loadUser(userId: Int): Ice<User> = with(Configuration.Default) {
ice {
// MainSync: Fresh data with cache fallback
userRepository.get(MainSync, userId)
}
}
// Force refresh (pull-to-refresh)
suspend fun refreshUser(userId: Int): Ice<User> = with(Configuration.Default) {
ice {
// Main: Always fetch fresh, update cache on success via repository
userRepository.get(Main, userId)
}
}
// Show cached data while loading
suspend fun getCachedUser(userId: Int): User? = with(Configuration.Default) {
nullable {
// Store: Return cached data immediately, or null
userRepository.get(Store, userId)
}
}
// Offline-first approach
suspend fun getUser(userId: Int): Ice<User> = with(Configuration.Default) {
ice {
// StoreSync: Try cache first, fetch from API if missing
userRepository.get(StoreSync, userId)
}
}
}
Flow: MainSync in Detail
Let's trace what happens with a MainSync operation:
with(Configuration.Default) {
ice {
userRepository.get(MainSync, userId = 123)
}
}
Scenario 1: API succeeds
- Call
apiDataSource.get(123)→ ReturnsUser("Alice") - Call
dbDataSource.put(123, User("Alice"))→ Caches the user - Return
User("Alice")
Scenario 2: API fails with network error, cache has data
- Call
apiDataSource.get(123)→ RaisesNoConnection - Check
mainFallbacks(NoConnection)→ Returnstrue - Call
dbDataSource.get(123)→ Returns cachedUser("Alice") - Return cached
User("Alice")
Scenario 3: API fails with network error, no cache
- Call
apiDataSource.get(123)→ RaisesNoConnection - Check
mainFallbacks(NoConnection)→ Returnstrue - Call
dbDataSource.get(123)→ RaisesDataNotFound - Return original error
NoConnection(notDataNotFound)
Flow: StoreSync in Detail
with(Configuration.Default) {
ice {
userRepository.get(StoreSync, userId = 123)
}
}
Scenario 1: Cache has valid data
- Call
dbDataSource.get(123)→ Returns cachedUser("Alice") - Return
User("Alice")(no API call!)
Scenario 2: Cache is empty
- Call
dbDataSource.get(123)→ RaisesDataNotFound - Check
storageFallbacks(DataNotFound)→ Returnstrue - Fall back to
MainSync:- Call
apiDataSource.get(123)→ ReturnsUser("Alice") - Call
dbDataSource.put(123, User("Alice"))→ Caches it
- Call
- Return
User("Alice")
Scenario 3: Cache is expired (Invalid)
- Call
dbDataSource.get(123)→ RaisesInvalid(cache expired) - Check
storageFallbacks(Invalid)→ Returnstrue - Fall back to
MainSync:- Call
apiDataSource.get(123)→ ReturnsUser("Alice") - Call
dbDataSource.put(123, User("Alice"))→ Updates cache
- Call
- Return
User("Alice")
Visual Summary
┌─────────────────────────────────────────────────────────────┐
│ Configuration │
│ - mainFallbacks: (Failure) -> Boolean │
│ - storageFallbacks: (Failure) -> Boolean │
│ - cache: CacheDataSource │
└─────────────────────────────────────────────────────────────┘
│
│ creates
▼
┌─────────────────────────────────────────────────────────────┐
│ Repository Strategy │
│ Based on Operation, returns appropriate repository │
└─────────────────────────────────────────────────────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐
│ Main │ │MainSync │ │StoreSync │
│ Store │ │ │ │ │
└────────┘ └──────────┘ └──────────┘
│ │ │
No fallback Uses mainFallbacks Uses storageFallbacks
Single source then mainFallbacks
Key Takeaways
- Use
MainSyncfor most cases - Fresh data with offline fallback is usually what you want - Use
Mainfor critical fresh data - When you must have up-to-date information - Use
Storefor offline mode - When you know data is cached or want to fail fast - Use
StoreSyncfor offline-first - When minimizing network calls is important - Keep default fallbacks - They handle common error scenarios correctly
- Operations are passed at call-time - Same repository, different behavior based on operation
- Fallbacks only apply to sync operations -
MainandStorenever use fallbacks
How DSL Builders Work with Configuration
All DSL builders in Archer (ice, either, nullable, bool, unit, etc.) can work with configurations in multiple ways. Understanding this is key to using Archer effectively.
Three Ways to Call DSL Builders
Every DSL builder can be called in three different ways:
// 1. Standalone with default configuration
ice {
userRepository.get(Main, userId)
}
// 2. Standalone with explicit configuration
ice(Configuration.Default) {
userRepository.get(Main, userId)
}
// 3. Inside a Configuration scope
with(Configuration.Default) {
ice {
userRepository.get(Main, userId)
}
}
Important differences:
- Method 1 & 2 call the top-level DSL function from
ArcherRaise.kt - Method 3 calls the DSL function as a member of
Configuration, implicitly using the scoped configuration - All three produce the same result when using
Configuration.Default, but allow different configuration strategies
ArcherRaise Context and Configuration Preservation
Every DSL builder provides an ArcherRaise context that preserves the parent configuration. The ArcherRaise class extends Configuration, giving you access to all configuration members within the DSL block.
// The outer ice uses Configuration.Default
ice(Configuration.Default) {
// Inside here, 'this' is ArcherRaise which extends Configuration
// You have access to all Configuration members
// You can call repository methods
userRepository.get(Main, userId)
// You can also create nested DSL blocks
val anotherResult = ice {
// This inherits the parent configuration
anotherRepository.get(Main, anotherId)
}
}
Nested Configuration Scoping
You can override the configuration for specific operations by scoping to a different configuration:
// Outer block uses default configuration
ice(Configuration.Default) {
val user = userRepository.get(Main, userId)
// Override configuration for a specific operation
with(Configuration.ignoreCache()) {
archerRecover(
block = {
// This operation bypasses cache
freshDataRepository.get(Main, dataId)
},
recover = { failure ->
raise(failure)
}
)
}
// Back to default configuration
user
}
Custom Configuration Example
// Define a custom configuration
object StrictConfiguration : Settings {
override val mainFallbacks = { _: Failure -> false }
override val storageFallbacks = { _: Failure -> false }
override val ignoreCache = false
override val cache = MemoizedExpirationCache()
override fun getCurrentTime() = Clock.System.now()
}
val strictConfig = Configuration(StrictConfiguration)
// Use it in different ways
suspend fun getUser(id: Int): Ice<User> {
// Method 1: Pass configuration explicitly
return ice(strictConfig) {
userRepository.get(Main, id)
}
}
suspend fun getUser2(id: Int): Ice<User> = with(strictConfig) {
// Method 2: Use configuration scope
ice {
userRepository.get(Main, id)
}
}
suspend fun getUser3(id: Int): Ice<User> = ice {
// Method 3: Default config, but override for specific operation
with(strictConfig) {
archerRecover(
block = { userRepository.get(Main, id) },
recover = { failure -> raise(failure) }
)
}
}
DSL Functions
Configuration provides multiple DSL functions for error handling with different return types:
ice
Returns Ice<A> representing three states: Idle, Content, or Error.
suspend fun getUser(id: Int): Ice<User> = ice {
userRepository.get(Main, id)
}
// Handling the result
when (val result = getUser(1)) {
is Ice.Idle -> println("Loading...")
is Ice.Content -> println("User: ${result.value}")
is Ice.Error -> println("Error: ${result.error}")
}
either / result
Returns Either<Failure, A> (Result is an alias for Either).
suspend fun getUser(id: Int): Either<Failure, User> = either {
userRepository.get(Main, id)
}
// Handling the result
getUser(1).fold(
ifLeft = { failure -> println("Failed: $failure") },
ifRight = { user -> println("Success: $user") }
)
nullable / nil
Returns A? - the value or null on failure.
suspend fun getUser(id: Int): User? = nullable {
userRepository.get(Main, id)
}
val user = getUser(1)
if (user != null) {
println("Got user: $user")
}
bool
Returns Boolean - true on success, false on failure.
suspend fun hasUser(id: Int): Boolean = bool {
userRepository.get(Main, id)
// If successful, returns true
}
if (hasUser(1)) {
println("User exists")
}
unit
Returns Unit - executes the block and discards the result.
suspend fun deleteUser(id: Int): Unit = unit {
userRepository.delete(id)
// Failures are swallowed, always returns Unit
}
Using archerRecover
archerRecover is a special function that requires a Settings context to work. Since ArcherRaise (the context inside DSL blocks) extends Configuration, and Configuration implements Settings, archerRecover is available within any DSL block.
Basic Usage
Within any DSL block, you can use archerRecover to handle failures explicitly:
ice {
// Inside ice block, 'this' is ArcherRaise which is a Settings
archerRecover(
block = {
// Try to get user from repository
userRepository.get(Main, userId)
},
recover = { failure ->
// Handle specific failures
when (failure) {
is DataNotFound -> getDefaultUser()
is NetworkFailure -> getCachedUser()
else -> raise(failure)
}
}
)
}
// With exception handling
ice {
archerRecover(
block = {
userRepository.get(Main, userId)
},
recover = { failure ->
// Handle raised failures
raise(failure)
},
catch = { exception ->
// Handle thrown exceptions
raise(Failure.Unhandled(exception))
}
)
}
Configuration Scoping with archerRecover
You can change the configuration for a specific archerRecover call by scoping to a different Settings:
ice(Configuration.Default) {
val user = userRepository.get(Main, userId)
// Use a different configuration for this specific operation
with(Configuration.ignoreCache()) {
archerRecover(
block = {
// This bypasses cache due to the ignoreCache configuration
freshRepository.get(Main, dataId)
},
recover = { failure ->
raise(failure)
}
)
}
user
}
Important: archerRecover preserves the configuration from its Settings context, which means fallback strategies and cache behavior are determined by the active configuration when archerRecover is called.
Custom Configuration
Ignoring Cache
Create a configuration that bypasses the cache:
val noCacheConfig = Configuration.ignoreCache()
// Use in a specific operation
noCacheConfig.ice {
userRepository.get(Main, userId)
}
Custom Settings
Implement custom Settings for advanced control (though this is rarely needed):
object CustomSettings : Settings {
// Only retry on network failures
override val mainFallbacks = { failure: Failure ->
failure is NetworkFailure
}
// Never fallback from storage
override val storageFallbacks = { _: Failure -> false }
override val ignoreCache = false
override val cache = MemoizedExpirationCache()
override fun getCurrentTime() = Clock.System.now()
}
// Use custom configuration with scoping
with(CustomSettings.configuration) {
ice {
userRepository.get(MainSync, userId)
}
}
Repository Configuration
Configuration provides helper functions for creating repository strategies:
cacheStrategy
Create a strategy with custom main and store data sources:
// Create strategy once at the top level
val strategy = with(Configuration.Default) {
cacheStrategy(
mainDataSource = apiDataSource,
storeDataSource = dbDataSource
)
}
// Use with different operations
with(Configuration.Default) {
ice {
strategy.get(Main, userId) // Main only
strategy.get(Store, userId) // Store only
strategy.get(MainSync, userId) // Main → Store
strategy.get(StoreSync, userId) // Store → Main
}
}
fallbackWith
Create a MainSync repository (main with fallback to store):
// Create repository once at the top level
val repository = with(Configuration.Default) {
apiDataSource fallbackWith dbDataSource
}
// Use in your application code
with(Configuration.Default) {
ice {
repository.get(userId)
}
}
expiresIn / expires
Add cache expiration to a repository strategy:
import kotlin.time.Duration.Companion.minutes
import com.m2f.archer.crud.cache.CacheExpiration
// Create strategies with expiration at the top level
val strategy = with(Configuration.Default) {
// Expire after duration
apiDataSource
.cacheWith(dbDataSource)
.expiresIn(5.minutes)
}
val strategy2 = with(Configuration.Default) {
// Custom expiration
apiDataSource
.cacheWith(dbDataSource)
.expires(CacheExpiration.After(10.minutes))
}
val strategy3 = with(Configuration.Default) {
// Never expire
apiDataSource
.cacheWith(dbDataSource)
.expires(CacheExpiration.Never)
}
Settings Properties
mainFallbacks
Function determining when to fallback from main data source to store:
val mainFallbacks: (Failure) -> Boolean
Returns true if the failure should trigger a fallback to the store.
storageFallbacks
Function determining when to fallback from store to main data source:
val storageFallbacks: (Failure) -> Boolean
Returns true if the failure should trigger a fallback to main.
ignoreCache
Boolean flag to bypass cache reads/writes:
val ignoreCache: Boolean
When true, all operations skip the cache.
cache
The cache implementation for storing expiration metadata:
val cache: CacheDataSource<CacheMetaInformation, Instant>
Default implementation is MemoizedExpirationCache, which is platform-sensitive and uses databases to persist cache metadata. See Cache Implementation and Testing for details on the default cache and how to configure alternative caches for testing.
getCurrentTime
Function providing the current time for cache expiration checks:
fun getCurrentTime(): Instant
Default uses Clock.System.now().
Best Practices
-
Use Default Configuration - Start with
Configuration.Defaultfor most cases// Simplest form - uses default configuration
ice { userRepository.get(Main, userId) } -
DSL Functions - Choose the right DSL based on your return type needs:
- UI states →
ice - Error handling →
either - Optional values →
nullable - Boolean checks →
bool
- UI states →
-
Configuration Scoping - Understand the three ways to call DSL builders:
// Method 1: Standalone with default (most common)
ice { /* ... */ }
// Method 2: Explicit configuration
ice(customConfig) { /* ... */ }
// Method 3: Configuration scope
with(customConfig) { ice { /* ... */ } } -
ArcherRaise Context - Remember that DSL blocks provide an
ArcherRaisecontext that preserves configuration:ice(Configuration.Default) {
// 'this' is ArcherRaise, which preserves the configuration
val result = ice { /* inherits parent config */ }
} -
Nested Configuration Override - Use
with()to override configuration for specific operations:ice {
// Default configuration
with(Configuration.ignoreCache()) {
// Override just for this operation
archerRecover(...)
}
} -
Custom Settings - Only create custom settings when you need specific fallback behavior
-
Cache Expiration - Always set expiration times for cached data
-
archerRecover - Use for explicit failure handling within DSL blocks, and remember it inherits the active
Settingscontext -
Testing Configurations - Always use custom testing configurations with
InMemoryDataSourceinstead of the defaultMemoizedExpirationCacheto avoid database dependencies and improve test performance. See Cache Implementation and Testing for examples -
Create Configuration at the Top - Use
settings.configurationto create Configuration from Settings, and create it once at the top of your call chain rather than creating Configuration instances throughout your code
See Also
- Basic Usage - Examples using configuration
- Result Types - Understanding Ice, Either, and other result types
- Repositories - Repository strategies and operations