Keys and namespaces
Key design is the one thing Store asks you to get right. Everything else has a sensible default. A key does not, because only you know how your data is shaped.
It is worth the attention because a StoreKey is doing two jobs at once, and they have different
consequences when you get them wrong.
The two jobs
public interface StoreKey {
public val namespace: StoreNamespace
public fun canonicalId(): String
}canonicalId() is identity. Two keys with the same namespace and the same canonical id are the
same key: they share one in-flight fetch, one resident value, one stale mark. Two keys with
different canonical ids share nothing. This is the lever that controls deduplication. Get it too
narrow and you fetch the same thing twice under two names. Get it too wide and two different things
collide on one cache entry.
namespace is the unit of bulk operations. It is what invalidateNamespace and clearNamespace
act on, and the durable watermark it carries covers keys the store has never even seen. This is the
lever that controls how much you can invalidate in one call.
Because identity is a String, the rule is simple: the canonical id must be stable for the
lifetime of the key, and it must contain everything that makes the result different. If two
requests would return different bytes, their canonical ids must differ.
The smallest correct key
import org.mobilenativefoundation.store6.core.StoreKey
import org.mobilenativefoundation.store6.core.StoreNamespace
class UserKey(val id: String) : StoreKey {
override val namespace: StoreNamespace = StoreNamespace("users")
override fun canonicalId(): String = id
}One namespace per record type, the record's own identifier as the canonical id. Start here. Most keys never need to be more than this.
When the id needs more than an identifier
If the same record can come back differently depending on the request, the difference belongs in the canonical id. A user record fetched with expanded relationships is not the same value as the same user fetched without them, and it must not overwrite it.
import org.mobilenativefoundation.store6.core.StoreKey
import org.mobilenativefoundation.store6.core.StoreNamespace
class UserKey(
val id: String,
val includeOrganization: Boolean = false,
) : StoreKey {
override val namespace: StoreNamespace = StoreNamespace("users")
override fun canonicalId(): String =
if (includeOrganization) "$id+org" else id
}Two things to avoid here. Do not put anything in the canonical id that changes between two requests you want deduplicated, such as a timestamp, a request id, or a nonce. And do not put a secret in it, because the canonical id is a cache key and it will be written to your source of truth.
Choosing namespaces
Namespaces are cheap. Use one per record type as the default, and split further when you want a smaller blast radius for bulk invalidation.
The question to ask is: what do I want to invalidate together? A pull-to-refresh on a user's profile screen should invalidate the user, not everything. A sign-out should clear everything. A "this organization's data changed" push notification is exactly the case for a per-organization namespace, because it lets one call invalidate the right subset instead of all of it.
import org.mobilenativefoundation.store6.core.Store
import org.mobilenativefoundation.store6.core.StoreKey
import org.mobilenativefoundation.store6.core.StoreNamespace
class Document(val id: String, val title: String)
class DocumentKey(
val organizationId: String,
val documentId: String,
) : StoreKey {
override val namespace: StoreNamespace = StoreNamespace("documents:$organizationId")
override fun canonicalId(): String = documentId
}
suspend fun onOrganizationChanged(
store: Store<DocumentKey, Document>,
organizationId: String,
) {
store.invalidateNamespace(StoreNamespace("documents:$organizationId"))
}The payoff
Once keys are right, the namespace-level operations become the tool you reach for:
invalidate(key)andinvalidateNamespace(namespace)mark values stale without removing them. Active streams are signaled on return and observe refetched data, and the resident value keeps serving in the meantime.clear(key),clearNamespace(namespace), andclearAll()destructively remove values.- The namespace and global watermarks are durable, so they cover keys that are not currently resident and survive process restart. Invalidating a namespace before a key has ever been fetched still makes that key's first read honest.
Which of invalidate and clear you want is its own decision, and it has its own guide: Invalidate or Clear.
Namespace equality
Store's internal key registry derives the namespace component of key identity from
namespace.value. The Bookkeeper contract likewise normalizes that component, and namespace
operations, by the same value. StoreNamespace does not override equals, so direct equality
between instances remains reference equality. Do not use that result to infer registry or
bookkeeping matches: independently constructed namespaces with the same .value address the same
namespace in both.
One store or many
Namespaces partition the maintenance blast radius within one store: use them when records share a
typed Store boundary but need separate invalidateNamespace or clearNamespace scopes.
Freshness is not a store-topology choice. Each stream or get call selects its own Freshness
policy, so callers using one store can make different read decisions.
Use separate stores when domains need independent typed value, failure, and lifecycle boundaries.
This separation does not make operations across stores atomic; no cross-store transaction is part
of the Store contract.
Source recorded 2026-08-10 ·main@a6a156e9· pre-6.0.0-alpha01