A cache lets requests reuse a report or an upstream response instead of rebuilding it each time. Treat cached values as replaceable copies: an entry can disappear through expiry, eviction, deletion, or backend loss. No expiry means no scheduled expiration, not guaranteed retention.
The *cache.Cache facade encodes Go values as JSON; its driver stores the bytes.
These examples assume an existing facade named store and an operation context
ctx. Choose and configure that store in Cache Drivers.
Use cache from arkoren.dev/core/service/cache, plus standard-library errors
and time. Focused snippets keep error results visible but omit routine error
propagation; your application must handle those errors.
Reading Values
Suppose your application builds a monthly sales report. Give its cached copy a small, JSON-compatible shape:
type Report struct {
Month string `json:"month"`
Orders int64 `json:"orders"`
RevenueCents int64 `json:"revenue_cents"`
}
A typed key supplies the exact storage name, value type, and write lifetime. Declare a ten-minute copy of March’s report:
key := cache.NewKey[Report]("reports.v1:2026-03", func(entry *cache.KeyBuilder) {
entry.TTL(10 * time.Minute)
})
Creating this definition performs no I/O. Cache Keys explains literal identities, namespaces, and reusable expiry policies. Sharing a key definition helps readers and writers agree; its Go type does not isolate backend entries.
To publish a freshly built report, write it through the facade:
err := store.Put(ctx, key, Report{
Month: "2026-03",
Orders: 318,
RevenueCents: 924500,
})
Put replaces both the value and its expiry. A key without a TTL writes without
expiry. Values must round-trip through encoding/json/v2; use exported fields
and JSON-compatible members, not channels or functions. Keep large blobs in
file storage, rather than a cache entry.
Read the report and distinguish ordinary absence from a storage or decoding failure:
report, err := store.Get(ctx, key)
if errors.Is(err, cache.ErrNotFound) {
// Load a replacement from the application's source of truth.
}
On success, report is a Report. A missing, expired, or evicted entry returns
an error wrapping cache.ErrNotFound. An existing value with incompatible JSON
returns a decoding error and remains stored. Writes capture a separate JSON
representation; reads decode a separate value. Mutating either your input or a
decoded result does not modify the stored copy.
To inspect expiry, use remaining, err := store.TTL(ctx, key): zero means an
existing entry has no expiry; absence returns cache.ErrNotFound. This differs
from key.TTL(), which reports the declared write policy. Reads do not renew
expiry. Has checks existence without decoding, but cannot guard a later write.
Delete removes an entry and succeeds if it is already absent.
Remembering Values
When every miss should rebuild the report, Remember combines lookup and
computation. Here buildReport is your application’s loader returning
(Report, error):
report, err = store.Remember(ctx, key, func() (Report, error) {
return buildReport(ctx, "2026-03")
})
A hit returns the decoded report without calling the loader. A miss runs the loader and stores its result using the key’s TTL. Undecodable stored JSON also triggers rebuilding and replacement. A schema or meaning change that still decodes successfully does not; version the physical key name or invalidate it.
Loader failure stores nothing and returns the error. If the loader succeeds but
encoding or storing its result fails, Remember returns that computed value
together with the error. Read or lock-acquisition failures return before the
loader runs, so an arbitrary error does not imply a usable value. Choose an
explicit fallback policy if the application must serve while the cache is down.
The computed result remains application-owned and is returned without cloning;
successful storage captures its JSON representation.
Concurrent misses sharing a backend coordinate through a ten-second
lease. After acquiring it, each caller reads again before computing;
hits never acquire the lease. Waiting ends when ctx ends. Independent memory
drivers do not coordinate, even if their keys have identical names.
Rememberdoes not renew its lease or guard the final write. A loader that outlasts ten seconds can overlap another loader and overwrite a newer result. Keep computation idempotent; move long refresh work to a schedule instead of relying on this lease for correctness.
After acquisition, release is best effort with a separate ten-second cleanup timeout. Cleanup retains context values but ignores request cancellation and deadlines; release errors are ignored because the lease expires. Return can therefore occur after the request deadline. The cleanup timeout bounds I/O only when the driver honors its context, and it does not bound the loader.
Consuming Values
A one-time download grant must not be returned to two concurrent requests.
Pull reads and removes an entry in one atomic driver operation. With an
existing cache.Key[string] named grantKey, consume it once:
grant, err := store.Pull(ctx, grantKey)
if errors.Is(err, cache.ErrNotFound) {
// Reject the missing, expired, or already consumed grant.
}
For one stored entry, one concurrent caller receives its bytes; later pulls observe absence. Removal happens before JSON decoding, so invalid JSON still consumes the entry. A lost network response can also leave the entry consumed without delivering the value to your application. This atomic consumption is not a transaction with the action authorized by the grant.
Claiming Entries
To reserve a short-lived refresh marker, use Add rather than a separate Has
and Put. Here marker is an existing cache.Key[bool] with a bounded TTL:
claimed, err := store.Add(ctx, marker, true)
if err != nil {
return err
}
if !claimed {
return nil // Another caller already holds the marker.
}
true, nil means this call stored the value; false, nil means an existing
unexpired entry and its TTL were left untouched. This claims an entry, not
completion of subsequent work. A crash after claiming leaves the marker until
expiry or deletion. For work that must not be lost, use a
queue job with an idempotent handler instead.
Guarded Writes
If another caller can retire or replace a report while an edit is pending,
check the observed value and write the replacement atomically. CompareAndPut
uses an exact raw-byte guard, not decoded JSON equality. Read those bytes before
preparing your replacement:
raw, err := store.Driver().Get(ctx, key.Name())
if err != nil {
return err
}
Raw access bypasses facade validation and telemetry. Do not decode and re-encode
the guard: the representation could change. Once your application has prepared a
Report named replacement, submit it only while those bytes still match:
applied, err := store.CompareAndPut(ctx,
cache.Match{Key: key.Name(), Value: raw}, key, replacement)
if err != nil {
return err // A transport failure can leave the outcome unknown.
}
if !applied {
return nil // Discard or reload the edit; never fall back to Put.
}
On success, the destination value and TTL are replaced together. A missing,
expired, or byte-different guard returns false, nil without changing either
entry or its TTL. The guard can name a different entry; a distinct guard and its
expiry remain untouched. An existing empty value can match empty bytes, but an
absent guard never matches. Invalid destination definitions fail before I/O,
including on a mismatch.
Unlike a lease, this check protects the cache write itself. It is not a monotonic version: deleting and recreating identical guard bytes allows an old update to match again (the ABA problem). Use a nonrepeating application version when that distinction matters. Do not restore retired security records. Before using guards for session retirement, follow the session writer rollout requirements so older unconditional writers cannot bypass rejection.
Match and raw Entry descriptors expose names and bytes without redaction.
Their slices are borrowed during the call, never modified or retained afterward;
do not mutate them concurrently or log sensitive contents. A custom driver must
perform comparison and writing atomically or return cache.ErrUnsupported.
Name-transforming wrappers must transform both guard and destination. Network
atomicity is not exactly-once execution or durability; Redis specifically avoids
replaying a possibly executed guarded write after an uncertain response.
Shared Infrastructure
Use counters for atomic integer updates and locks to reduce duplicate work. Sessions and rate limiting also depend on the cache: losing their entries logs visitors out or resets request allowances. Independent replica-local caches keep independent sessions and allowances.
Choose a separate backend or Redis database when state needs independent sizing or clearing. A facade name only labels telemetry, and namespaces do not provide bulk-clear or isolation boundaries. Do not share a session cache with entries your application intends to clear indiscriminately. Monitoring explains operation spans, hit ratios, and privacy boundaries.
For tests, the memory driver needs no server. Its clock works
with testing/synctest, so expiry tests need not sleep in
real time. Custom drivers can run the shared
conformance suite, cachetest.Run.
For every function and option, see the cache API reference.