←Back to Blog
Layered caching in Go: local memory, Redis, and Badger

10/24/2026 • 47 min read

Layered caching in Go: local memory, Redis, and Badger

A cache can start as a map and end up with backend-specific branches scattered through query code. Local memory, Redis, and disk each have useful properties, but the business logic should not need to know which one returned a value.

This design uses the CacheGetter, CacheSetter, and CacheDeleter signatures from campaign-go/internal/cache/cache_interface.go. The three-layer composition below is an illustrative design, not a claim that campaign-go already runs this exact local/Redis/Badger topology.

Why use three layers?

  • L1 local memory serves a process's hot keys without a network request. Bound its size and make access concurrency-safe. A TTL map alone is not an LRU.
  • L2 Redis shares cached values across application instances. It saves repeated source loads, but introduces network latency and availability concerns.
  • L3 Badger stores a larger working set on local disk. It can survive a process restart if the volume survives, but it is not shared between instances and is not automatically a source of truth.

Most applications should start with L1 memory and L2 Redis, or just one cache. Add L3 only when measurements justify another stored copy: expensive source queries or computations, a working set too large for the Redis budget, reusable snapshots across restarts, or a deliberately designed degraded-read path. An extra tier also adds invalidation, disk or database operations, and another opportunity to return stale data.

Badger is only one possible L3 strategy. MySQL or PostgreSQL can hold a materialized cache in a dedicated table when shared persistence, SQL access, or existing operational tooling makes that useful. Such a store must implement expiry explicitly, filter expired rows on reads, and clean them up; SQL databases do not automatically provide Redis-style per-row TTL. Account for indexes, connection pools, write amplification, and the cleanup workload. Cache failures or traffic should not exhaust the authoritative database's resources.

A SQL database serving authoritative records is different: it is the source loaded by GetOrSet after a complete cache miss, not a third cache simply because it follows Redis. Call it L3 only when it holds derived, disposable cached values behind CacheStore, with a defined retention and invalidation policy.

Here, the lookup order is local memory, Redis, then Badger. These numbers describe our chosen policy, not universal speed rankings: local disk may beat a remote Redis request. Measure the actual workload before choosing the order. If disk persistence or a shared tier has no useful role, omit it.

Real-world reasons to add L3

These are illustrative workloads, not claims about this project's production traffic. Most applications should keep one cache or L1 plus Redis until measurements show a reason for another tier.

Expensive reports and analytics

A dashboard report aggregates millions of records and takes several seconds to generate. L1 keeps popular reports in memory, Redis shares recent results across instances, and a dedicated PostgreSQL or MySQL cache table retains generated reports longer. A Redis eviction or restart need not force another expensive aggregation.

Include tenant ID, report parameters, and a source-data version in the cache key. The SQL cache stores a derived report, not the authoritative records used to generate it. Filter expired results on reads and clean them up. An L3 lookup and write must cost less than the computation it avoids.

Product or content snapshots across restarts

A service assembles product documents from catalog data, pricing rules, and external metadata. Rebuilding every document after deployment creates a burst of source requests. A Badger L3 on a persistent volume can retain those derived documents across process restarts, while Redis provides shared reuse.

This only helps when the volume survives. A new instance with an empty volume still starts cold. Include schema and source-data versions, and reconcile invalidations missed while the process was offline. Do not reuse an old price or stock snapshot where the operation requires an authoritative check.

Large, reusable computation results

A service generates document previews, image metadata, or other deterministic processing results. The working set is too expensive to retain entirely in Redis. L1 and L2 serve hot results; a disk-backed L3 retains more results at a different storage cost.

A key based on the input content hash and processing version makes reuse explicit. Large binary artifacts may fit object storage better than Badger or SQL. An object-storage cache would be another CacheStore strategy, with its own expiry and error translation.

Deliberate degraded reads during an outage

Public content or product descriptions may tolerate an older snapshot when an upstream service is unavailable. A persistent L3 can hold last-known-good data, but serving it needs an explicit policy: eligible data, maximum age, allowed failure types, and visible degraded-operation metrics.

The read loop in this article does not implement outage fallback: it stops on a backend error. Supporting degraded reads requires a separate bounded policy, not treating every failure as a miss. Serving data beyond its normal expiry also needs an explicit stale-data retention/read mechanism; this interface's ordinary Get hides expired entries. Do not apply this policy to balances, permissions, or other correctness-sensitive decisions without a design that meets their requirements.

When L3 will not help

If source reads are already cheap, Redis hit rates are high, or freshness requirements rule out older copies, another cache may create more work than it saves. Compare source-load reduction, total latency, storage cost, and invalidation complexity before adding it.

Architecture and read workflow once layers are chosen

Architecture showing a common CacheStore contract, ordered reads through local memory, Redis and Badger, and source loading on a complete miss.

A hit returns immediately. Only a cache miss advances to the next store. This baseline stops on backend failure rather than silently treating it as a miss. All misses return to the cache-aside caller, which loads the authoritative source.

The baseline intentionally does not promote hits. That choice keeps the original TTL from accidentally restarting at every layer.

Start with the existing contract

go
type CacheGetter interface {
    Get(ctx context.Context, key string) ([]byte, error)
}

type CacheSetter interface {
    Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
}

type CacheDeleter interface {
    Del(ctx context.Context, key string) error
}

type CacheStore interface {
    CacheGetter
    CacheSetter
    CacheDeleter
}

A read-only helper can accept CacheGetter; a layered store needs the full contract. Values are bytes, so serialization stays outside the storage strategies. A codec can use JSON or another wire format without changing lookup order.

Use ErrCacheMiss for absent or expired keys and preserve real backend errors. This matches campaign-go's helper convention. Each concrete store implements these methods directly; there is no need to wrap an owned type just to implement an interface.

Which design patterns are doing the work?

For an introduction to interchangeable behavior, wrappers, and explicit dependency wiring, see Go Design Patterns: Start With the Problem, Not the Pattern, especially Strategy, Decorator, and dependency injection.

This design combines storage strategies, TTL decorators, a composite store, ordered fallback reads, and cache-aside loading. Each solves a different problem.

Strategy: choose how bytes are stored or encoded

LocalStore, RedisStore, and BadgerStore implement the same CacheStore contract. Each handles storage differently, while the code using it calls Get, Set, and Del without a backend type switch. Construction selects the implementations. The Encoder contract shown later is a separate strategy: switching from JSON to another serialization format does not require changing the storage code.

Strategy makes implementations interchangeable. It does not decide lookup order, how many layers receive a write, or how failures are handled.

Decorator: attach a TTL policy to one store

WithTTL returns a store with the same interface as the wrapped store. It changes Set by supplying its configured TTL and delegates Get and Del unchanged. This lets memory retain values for ten seconds while Redis retains them for five minutes, without putting TTL arguments in every helper call.

The decorator adds policy around an existing implementation. The concrete backend still owns the mechanics of expiry.

Composite: treat several stores as one

LayeredStore implements CacheStore and holds children that also implement CacheStore. A helper can therefore accept a single Redis store or a layered store through the same contract. This is the Composite idea: expose a group through the interface used by its individual members.

The composite defines what a group operation means. Here, Set and Del fan out to all children and join their errors; Get uses ordered fallback. Those are explicit policies, not guarantees provided by the interface.

Chain of Responsibility: try the next layer on a miss

The read loop follows a Chain of Responsibility-style flow: L1 gets the first chance to answer, then L2, then L3. A hit handles the request and stops traversal. A miss passes it onward. In this implementation, a backend error stops the chain instead of falling through.

The chain is represented by an ordered slice rather than linked objects. This description applies to reads; writes and deletes broadcast to every configured layer instead.

Cache-aside: load the source only after a complete miss

GetOrSet first reads the cache. When every layer misses, it calls the source loader, encodes the result, and populates the cache. That is cache-aside loading, implemented as a helper rather than repeated at every call site. Source updates and invalidation still require application coordination; this helper does not synchronize them automatically.

text
GetOrSet                         cache-aside loading
  |-- Encoder                    serialization strategy
  `-- LayeredStore               composite CacheStore
        |-- ordered Get          chain-style fallback
        |-- WithTTL(LocalStore)  decorator + storage strategy
        |-- WithTTL(RedisStore)  decorator + storage strategy
        `-- WithTTL(BadgerStore) decorator + storage strategy

These names explain the responsibilities already present in the code. There is no need to introduce extra abstractions just to match a pattern catalog.

The following code is the complete orchestration core. Together with the interface declarations above, it needs context, errors, fmt, and time. It does not include production backend implementations.

go
var ErrCacheMiss = errors.New("cache miss")

type LayeredStore struct {
    layers []CacheStore
}

func NewLayeredStore(layers ...CacheStore) *LayeredStore {
    return &LayeredStore{
        layers: append([]CacheStore(nil), layers...),
    }
}

func (s *LayeredStore) Get(ctx context.Context, key string) ([]byte, error) {
    for i, layer := range s.layers {
        if err := ctx.Err(); err != nil {
            return nil, err
        }
        value, err := layer.Get(ctx, key)
        if err == nil {
            return value, nil
        }
        if !errors.Is(err, ErrCacheMiss) {
            return nil, fmt.Errorf("cache layer %d get: %w", i+1, err)
        }
    }
    return nil, ErrCacheMiss
}

func (s *LayeredStore) Set(
    ctx context.Context, key string, value []byte, ttl time.Duration,
) error {
    var failures []error
    for i, layer := range s.layers {
        if err := layer.Set(ctx, key, value, ttl); err != nil {
            failures = append(failures,
                fmt.Errorf("cache layer %d set: %w", i+1, err))
        }
    }
    return errors.Join(failures...)
}

func (s *LayeredStore) Del(ctx context.Context, key string) error {
    var failures []error
    for i, layer := range s.layers {
        if err := layer.Del(ctx, key); err != nil {
            failures = append(failures,
                fmt.Errorf("cache layer %d delete: %w", i+1, err))
        }
    }
    return errors.Join(failures...)
}

var _ CacheStore = (*LayeredStore)(nil)

Configure TTL per store with WithTTL

A single TTL on GetOrSet would force memory, Redis, and disk to use the same retention policy. Configure each layer once instead. The low-level Set keeps its TTL parameter for compatibility with the existing contract; the decorator overrides that argument on every write.

go
type ttlStore struct {
    CacheStore
    ttl time.Duration
}

// Option decorates a store without changing the CacheStore contract.
type Option func(CacheStore) CacheStore

func Wrap(store CacheStore, opts ...Option) CacheStore {
    for _, opt := range opts {
        store = opt(store)
    }
    return store
}

func WithTTL(ttl time.Duration) Option {
    if ttl < 0 {
        panic("cache TTL must not be negative")
    }
    return func(store CacheStore) CacheStore {
        return &ttlStore{CacheStore: store, ttl: ttl}
    }
}

func (s *ttlStore) Set(
    ctx context.Context, key string, value []byte, _ time.Duration,
) error {
    return s.CacheStore.Set(ctx, key, value, s.ttl)
}

Embedding delegates Get and Del unchanged. Set applies the configured policy, even if a caller supplies another TTL. A negative TTL is a startup configuration error here; zero explicitly means no expiry. This minimal decorator is an illustrative implementation, not a copy of campaign-go's option machinery.

Wrap(store, opts...) uses decorator-style functional options: each option accepts a store and returns a decorated store. This differs from options that mutate a configuration struct. Wrap(store) returns the original store unchanged. Options apply left to right, so the last decorator is outermost. With two TTL options, the first-applied, innermost TTL ultimately wins because it overrides the value passed by the outer wrapper; configure TTL only once per store. Other decorators may also depend on order, so document and test it. Supply non-nil stores and options; this minimal example does not detect typed-nil interface values.

Within a package named cache, these declarations expose cache.Wrap(store, cache.WithTTL(...)) to callers. Functional options configure composition, while the returned ttlStore supplies the Decorator behavior. They do not change the storage Strategy or move TTL into helpers.

Given initialized stores, compose them like this:

go
cache := NewLayeredStore(
    Wrap(localStore, WithTTL(10*time.Second)),
    Wrap(redisStore, WithTTL(5*time.Minute)),
    Wrap(badgerStore, WithTTL(time.Hour)),
)

These example durations express different retention policies, not a promise that a value is fresh for an hour. Wrap every layer: an unconfigured backend receiving TTL zero may retain data indefinitely.

There is no switch over Redis, Badger, or memory types. Swapping an implementation changes construction, not the read loop. Configure non-nil, concurrency-safe stores before serving requests; ownership and closing the Redis client or Badger database belong to application startup/shutdown, not this interface.

Helpers: GetAny, SetAny, and GetOrSet

Keep argument order consistent: ctx, store, encoder, .... Serialization is another strategy, independent of where bytes are stored. This example calls that contract Encoder; campaign-go calls the equivalent role Codec. The helper argument order below is the article's API choice, not the existing repository's exact signature.

go
type Encoder interface {
    Encode(value any) ([]byte, error)
    Decode(data []byte, out any) error
}

type JSONEncoder struct{}

func (JSONEncoder) Encode(value any) ([]byte, error) {
    return json.Marshal(value)
}

func (JSONEncoder) Decode(data []byte, out any) error {
    return json.Unmarshal(data, out)
}

func GetAny(
    ctx context.Context, store CacheGetter, encoder Encoder,
    key string, out any,
) (bool, error) {
    data, err := store.Get(ctx, key)
    if errors.Is(err, ErrCacheMiss) {
        return false, nil
    }
    if err != nil {
        return false, err
    }
    if err := encoder.Decode(data, out); err != nil {
        return false, fmt.Errorf("decode cache value: %w", err)
    }
    return true, nil
}

func SetAny(
    ctx context.Context, store CacheSetter, encoder Encoder,
    key string, value any,
) error {
    data, err := encoder.Encode(value)
    if err != nil {
        return fmt.Errorf("encode cache value: %w", err)
    }
    // WithTTL on each layer supplies that layer's configured expiry.
    return store.Set(ctx, key, data, 0)
}

func GetOrSet[T any](
    ctx context.Context, store CacheStore, encoder Encoder,
    key string, load func(context.Context) (T, error),
) (T, error) {
    var value T
    found, err := GetAny(ctx, store, encoder, key, &value)
    if err != nil {
        return value, err
    }
    if found {
        return value, nil
    }
    if err := ctx.Err(); err != nil {
        return value, err
    }
    value, err = load(ctx)
    if err != nil {
        return value, err
    }
    if err := SetAny(ctx, store, encoder, key, value); err != nil {
        return value, fmt.Errorf("populate cache: %w", err)
    }
    return value, nil
}

Add encoding/json to the earlier imports. GetAny needs only a getter and SetAny only a setter. Neither helper accepts TTL. GetOrSet combines them and calls the source loader only on a miss; backend and decode failures are returned rather than disguised as misses. Unlike a permissive decoder helper, this example treats empty JSON bytes as a decode error.

For example, given a repository and a user ID:

go
user, err := GetOrSet(
    ctx, cache, JSONEncoder{}, "user:"+userID,
    func(ctx context.Context) (*User, error) {
        return repository.FindByID(ctx, userID)
    },
)

On a hit, decode and return. On a miss, load once for this call, encode once, and fan out through the TTL-configured layers. A source error is never written to cache. A cache-write failure returns the loaded value and an error; callers must deliberately choose whether to serve that value. A nil pointer with a nil source error becomes JSON null and is cached, so decide whether negative caching is appropriate. This simple helper does not coalesce concurrent misses.

text
GetOrSet(ctx, store, encoder, key, load)
  |
  +-- GetAny -> ordered reads -> hit -> decode -> return
  |
  +-- all miss -> load(ctx) -> SetAny -> encode once
                                  |
                             LayeredStore.Set(..., 0)
                               /       |       \
                      WithTTL(10s)  WithTTL(5m)  WithTTL(1h)
                           Local      Redis      Badger

Choosing an encoder

Use JSON first when readable cache entries, easy debugging, and interoperability matter. encoding/json is available in the standard library. Decode into concrete types rather than generic maps when integer precision matters, and define schema compatibility explicitly.

Use MessagePack when measurements show that serialization CPU, payload size, or network bandwidth are material costs. It is a binary serialization format, not encryption or compression, and it is not automatically faster for every payload. A MsgpackEncoder can implement this same interface by delegating to a maintained library's marshal/unmarshal functions. Check field naming, timestamp and extension conventions across readers, and benchmark representative values before switching.

Use an EncryptedEncoder when cache values must remain confidential to someone who can read Redis dumps or Badger files but does not hold the application key. It decorates JSON or MessagePack rather than replacing serialization:

text
Encode: value -> JSON / MessagePack -> authenticated encryption -> bytes
Decode: bytes -> authenticate and decrypt -> JSON / MessagePack -> value

The conceptual composition is EncryptedEncoder{Inner: JSONEncoder{}} or the corresponding MessagePack encoder, plus an explicitly configured key provider. This is an API sketch, not a complete encryption implementation. Use a vetted authenticated-encryption construction such as AES-GCM or ChaCha20-Poly1305, with nonce generation and per-key usage limits appropriate to the chosen algorithm. Never reuse a nonce with a key. Store a version, key ID, nonce, and ciphertext/tag in a validated envelope; authenticate the header. Reject malformed envelopes and authentication failures before decoding, without falling back to plaintext. Keep keys outside the cache, rotate them through a key manager, and retain old decryption keys until old entries expire or are migrated.

Encryption protects values, not key names, TTLs, access patterns, or plaintext in application memory. It does not replace Redis TLS, access controls, or disk permissions. This Encoder API does not receive the cache key, so it cannot bind ciphertext to that key with authenticated associated data. If protection against swapping valid ciphertext between keys is required, extend the API to supply key/tenant context and bind it during encryption and decryption.

Encode once before fan-out means all tiers use the same format, including encrypted L1 bytes. Per-tier plaintext/ciphertext policies require a different boundary, not silently changing the encoder during fallback. Version or namespace cache keys when changing JSON to MessagePack, encryption keys/envelopes, or schemas; old readers and new writers can overlap during deployment. Treat decoding/authentication failures as errors, not ordinary misses that conceal corruption.

Write and delete workflow

text
Source load succeeds
        |
        v
Encode value once
        |
        v
Set L1 -> Set L2 -> Set L3
  |         |         |
  +---------+---------+
            |
    Join/report errors

Source update succeeds
        |
        v
Del L1 -> Del L2 -> Del L3
        |
        v
Publish invalidation to other instances

Set and Del attempt every configured store and report joined failures. A failed L1 write does not prevent an L2 attempt. A failed delete must not skip another layer that still has an old value. These operations are not transactions: an error may mean some stores changed and others did not. The caller must decide whether to retry, return a loaded source value despite a cache-write failure, or reject the operation. Retries after cancellation need a separate bounded context and policy, not an unbounded background goroutine.

The source-load arrow belongs to the caller. Load only when errors.Is(err, ErrCacheMiss) succeeds; a Redis outage or canceled request is not an ordinary miss. A fail-open variant is possible, but must distinguish retryable cache failures from cancellation and data corruption, limit source traffic, and record degraded operation.

What each backend must translate

The implementations must agree on the contract, not just the method names:

  • Local memory uses a lock or concurrency-safe structure, expiry checks, capacity limits, and defensive byte copies. Returning a mutable internal slice lets callers corrupt future reads.
  • Redis translates redis.Nil into ErrCacheMiss. Other errors remain errors. Use the request context for commands and avoid sharing the namespace with durable keys.
  • Badger translates badger.ErrKeyNotFound into ErrCacheMiss. Copy item bytes before the read transaction ends. Use write transactions for sets and deletes, and manage disk growth and value-log garbage collection operationally. Its transaction calls do not accept a request context like Redis commands do, so check cancellation at operation boundaries without promising interruption of an in-progress transaction.

For this proposed contract, use positive TTLs for expiring values, zero for no expiry, and reject negative TTLs consistently in all adapters. Deletes should succeed on missing keys. TTL resolution differs between stores, so test and document the effective expiry rather than assuming identical precision.

TTL is part of the design

Get returns bytes, not the original expiration or remaining TTL. If an L3 value has one second left, promoting it into L1 with a fresh five-minute TTL could serve stale data long after the original expiry.

This article's read path avoids that by not promoting. Each WithTTL layer intentionally uses a different retention period: ten seconds in memory, five minutes in Redis, and one hour in Badger. Expiry in L1 does not force a fresh source load; a later layer may still return its older copy. Sequential writes also start their TTLs at slightly different times; they do not establish one shared deadline. If you need strict freshness across layers, carry a shared absolute expiration in an encoded envelope and validate it on every read, or introduce a metadata-aware contract. A shorter promotion TTL can bound additional staleness, but cannot preserve an unknown original deadline.

Also decide whether L3 fallback is acceptable after an L2 miss. Redis might have expired or evicted a newer value while Badger still holds an older one. A versioned key or checked expiration helps; layer ordering alone does not make that fallback correct.

Invalidation across instances

Deleting local L1 and L3 only affects the instance handling the update. Other processes can retain their own copies. The following implementation publishes invalidated keys through Redis. Receivers clear only their own memory and Badger stores; they do not delete the shared Redis cache again or republish.

This example uses a dedicated versioned channel, not campaign-go's legacy clearLocalCache wire protocol. It needs log/slog and redis "github.com/redis/go-redis/v9" in addition to the earlier imports. Supply a unique ID for each process start and a trusted, environment-scoped channel name.

go
type invalidationEvent struct {
    Version int      `json:"version"`
    Sender  string   `json:"sender"`
    Keys    []string `json:"keys"`
}

type Invalidator struct {
    Client   *redis.Client
    Channel  string
    SenderID string
    Local    []CacheDeleter // This instance's L1 and L3 only.
}

// Invalidate runs after the authoritative source update commits.
func (i *Invalidator) Invalidate(
    ctx context.Context, store CacheDeleter, keys ...string,
) error {
    if len(keys) == 0 {
        return nil
    }
    data, err := json.Marshal(invalidationEvent{
        Version: 1, Sender: i.SenderID, Keys: keys,
    })
    if err != nil {
        return err
    }
    var failures []error
    for _, key := range keys {
        if err := store.Del(ctx, key); err != nil {
            failures = append(failures, fmt.Errorf("invalidate cache: %w", err))
        }
    }
    // Try publishing even if a local/shared delete failed.
    if err := i.Client.Publish(ctx, i.Channel, data).Err(); err != nil {
        failures = append(failures, fmt.Errorf("publish invalidation: %w", err))
    }
    return errors.Join(failures...)
}

func (i *Invalidator) apply(ctx context.Context, payload string) error {
    var event invalidationEvent
    if err := json.Unmarshal([]byte(payload), &event); err != nil {
        return fmt.Errorf("decode invalidation: %w", err)
    }
    if event.Version != 1 || event.Sender == "" || len(event.Keys) == 0 {
        return errors.New("invalid invalidation event")
    }
    // Applying our own event is safe and retries a failed local delete.
    // Receivers never publish, so it cannot form a message loop.
    var failures []error
    for _, key := range event.Keys {
        for _, local := range i.Local {
            if err := local.Del(ctx, key); err != nil {
                failures = append(failures, fmt.Errorf("clear local tier: %w", err))
            }
        }
    }
    return errors.Join(failures...)
}

// Start waits for subscription acknowledgement before returning.
// Run it before serving requests; cancel ctx and await done on shutdown.
func (i *Invalidator) Start(
    ctx context.Context, log *slog.Logger,
) (<-chan struct{}, error) {
    sub := i.Client.Subscribe(ctx, i.Channel)
    if _, err := sub.Receive(ctx); err != nil {
        _ = sub.Close()
        return nil, fmt.Errorf("subscribe invalidation: %w", err)
    }
    done := make(chan struct{})
    go func() {
        defer close(done)
        defer sub.Close()
        messages := sub.Channel()
        for {
            select {
            case <-ctx.Done():
                return
            case msg, ok := <-messages:
                if !ok {
                    log.Error("cache invalidation subscription closed")
                    return
                }
                opCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
                err := i.apply(opCtx, msg.Payload)
                cancel()
                if err != nil {
                    log.Error("cache invalidation failed", "error", err)
                }
            }
        }
    }()
    return done, nil
}

Wire Local to the initialized memory and Badger stores, never the layered store containing Redis. If L3 is a shared MySQL or PostgreSQL cache instead, delete it through the origin's layered store; omit it from peer-local deletion targets, just like shared Redis. Give the subscriber an application-lifecycle context, not an HTTP request context. After a source update, call invalidator.Invalidate(ctx, cache, key), where cache is the full layered store. Duplicate events are harmless because delete is idempotent. Unlike campaign-go's signal path, this simplified receiver also applies self-originated events to retry local deletion; sender IDs remain useful for tracing.

text
Instance A: commit source update
  -> Del through layered cache (A's memory + Redis + A's Badger)
  -> Publish keys, even when a delete returned an error
           |
     Redis Pub/Sub
       /       \
 Instance B   Instance C
 Del L1/L3    Del L1/L3
 no publish   no publish

These are non-atomic steps. A process can crash after committing the source but before publishing; a successful publish does not acknowledge peer deletion. Pub/Sub reconnects cannot replay missed messages, and slow consumers can lose notifications. This example logs receiver errors but does not implement retries, durable delivery, or reconnection reconciliation. A production implementation should bound event sizes, restrict channel access, monitor subscription health, and reconcile or flush local tiers after gaps, including persisted Badger data after restart. Use TTLs, versioned keys, or an outbox plus a durable event mechanism when stronger guarantees are required. Short TTLs limit staleness; Pub/Sub alone is not a consistency guarantee.

Even within one instance, a reader can fetch an old source value before an update and populate the cache after invalidation. For data where that race matters, use version checks, generation keys, or another explicit consistency protocol. Do not cache permission decisions or balances under an eventual-consistency policy unless the application can tolerate it.

When this is worth building

Use multiple layers when measurements show a benefit: an L1 that meaningfully reduces network reads, a shared L2 that avoids duplicate source work, or a persistent L3 that reduces cold-start loading. Track hit rates, latency, backend errors, partial writes, and source load rate per layer. Use singleflight or another load-coalescing mechanism to limit concurrent source loads; per-process coalescing does not coordinate all instances.

Test the orchestration independently of the backends: L1 short-circuits; L2 is consulted only after an L1 miss; all misses return the sentinel; backend errors and cancellation stop reads; and writes/deletes attempt all layers and report partial failure. Then test each adapter against the actual backend for expiry, copying, and error normalization.

The interface removes backend-specific branching. The purpose of layering is to reduce expensive work. Freshness, failure handling, and invalidation still need their own policies.

Further reading: Redis Go client · Badger.

Other posts that might interest you...