A type-safe caching decorator for go-repository-bun repositories using sturdyc for stampede-safe caching.
- Drop-in compatibility: Implements the same
Repository[T]interface - Stampede protection: Non blocking reads with inflight request deduplication
- Type-safe caching: Generic implementation maintains full type safety
- Tag based invalidation: Registers read keys under tags and invalidates on writes
- Selective caching: Only read operations are cached; writes pass through
- Transaction awareness: Bypasses cache for transactional operations
- Safe key generation: Caches deterministic value inputs and bypasses opaque function-valued criteria
- Configurable: Pluggable key serialization and cache configuration
go get github.com/goliatone/go-repository-cachepackage main
import (
"context"
"time"
"github.com/goliatone/go-repository-cache/cache"
"github.com/goliatone/go-repository-cache/repositorycache"
// Your existing repository
"yourproject/repositories"
)
func main() {
// Your existing repository
baseRepo := repositories.NewUserRepository(db)
// Configure cache
cacheConfig := cache.Config{
Capacity: 10_000,
NumShards: 256,
TTL: 30 * time.Minute,
EvictionPercentage: 10,
MissingRecordStorage: true,
}
// Create cache components
cacheService, err := cache.NewCacheService(cacheConfig)
if err != nil {
panic(err)
}
keySerializer := cache.NewDefaultKeySerializer()
// Wrap with caching
cachedRepo := repositorycache.New(baseRepo, cacheService, keySerializer)
// Use exactly like your original repository
ctx := context.Background()
user, err := cachedRepo.GetByID(ctx, "user-123") // First call hits DB
user, err = cachedRepo.GetByID(ctx, "user-123") // Second call hits cache
}Need to target additional identifier fields (for example, a Slug column)? Pass them explicitly:
cachedRepo := repositorycache.NewWithIdentifierFields(
baseRepo,
cacheService,
keySerializer,
"Slug", "ExternalID",
)import (
"time"
"github.com/goliatone/go-repository-cache/cache"
"github.com/goliatone/go-repository-cache/pkg/di"
)
func main() {
// Configure cache
config := cache.Config{
TTL: 30 * time.Minute,
// ... other options
}
// Create container
container, err := di.NewContainer(config)
if err != nil {
panic(err)
}
// Your base repository
baseRepo := repositories.NewUserRepository(db)
// Get cached version
cachedRepo := container.NewCachedRepository(baseRepo)
// Use as normal
users, total, err := cachedRepo.List(ctx, repository.Limit(10))
}Cached Operations (when called without SelectCriteria):
Get,GetByID,GetByIdentifierList,Count
Pass-through Operations (consistency guarantee):
- Reads with one or more function-valued
SelectCriteria - All write operations (
Create,Update,Delete, etc.) - All transaction methods (
*Txvariants) - Raw SQL queries
The library automatically generates stable cache keys from:
- Method name (
GetByID,List, etc.) - Deterministic value parameters such as IDs, identifiers, and active scope state
// These value-only reads are cached.
repo.GetByID(ctx, "123")
// SelectCriteria are functions whose captured values cannot be recovered safely.
// Criteria-bearing reads therefore execute against the base repository.
repo.GetByID(ctx, "123", repository.SelectDeletedAlso())
repo.List(ctx, repository.SelectRawProcessor(func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Where("active = ?", true)
}))This fail-safe rule prevents closures created at the same call site with different captured values from sharing a cached result. If filtered-read caching is required, the caller must first define an explicit semantic signature and matching invalidation contract; function code pointers are never accepted as query identities.
When your base repository uses the go-repository-bun scope system, the decorator automatically folds the active scope names and any WithScopeData payloads into every cached key. Tenant/session specific contexts therefore never share cached rows:
ctx := repository.WithSelectScopes(ctx, "tenant")
ctx = repository.WithScopeData(ctx, "tenant", tenantID)
// Key includes both the "tenant" scope name and the concrete tenantID value
// Scope-only state is included in cached value-only reads.
user, err := cachedRepo.GetByID(ctx, "user-123")
// Adding SelectCriteria makes the read pass through until a semantic filtered-read
// cache contract is provided.
users, total, err := cachedRepo.List(ctx, repository.SelectPaginate(25, 0))Scopes registered or defaulted through the cached repository are forwarded to the underlying repository:
cachedRepo.RegisterScope("tenant", tenantScopeDefinition)
if err := cachedRepo.SetScopeDefaults(repository.ScopeDefaults{
Select: []string{"tenant"},
}); err != nil {
panic(err)
}This keeps cache keys aligned with whatever filters the base repository enforces while respecting WithoutDefaultScopes, WithSelectScopes, and other helpers.
With a registered ScopeByFieldRequired("group", "group_id") select default,
missing, wrong, and unregistered scope contexts cannot reuse another Group's
warmed entries. This applies to Get, GetByID, GetByIdentifier, List, and
Count, including defaults configured before wrapping the base repository.
Configure scopes at startup and treat WithoutDefaultScopes as a trusted escape;
caching does not add authorization or insertion ownership stamping.
Operations within transactions bypass the cache to ensure consistency:
// This hits the cache
user, err := repo.GetByID(ctx, "123")
// This bypasses the cache and goes directly to DB
err = repo.WithTx(ctx, func(ctx context.Context, tx bun.IDB) error {
user, err := repo.GetByIDTx(ctx, tx, "123") // Direct DB access
return repo.UpdateTx(ctx, tx, updatedUser)
})config := cache.Config{
TTL: 30 * time.Minute,
NumShards: 256,
EvictionPercentage: 10,
// background refresh before expiry
EarlyRefresh: &cache.EarlyRefreshConfig{
MinAsyncRefreshTime: 20 * time.Minute,
MaxAsyncRefreshTime: 25 * time.Minute,
SyncRefreshTime: 30 * time.Minute,
RetryBaseDelay: 1 * time.Second,
},
// Handle missing records
MissingRecordStorage: true,
}Implement your own key generation strategy:
type CustomKeySerializer struct {
prefix string
}
func (s *CustomKeySerializer) SerializeKey(method string, args ...any) string {
// Your custom logic here
return fmt.Sprintf("%s:%s:%s", s.prefix, method, /* serialize args */)
}
// Use with repository
cachedRepo := repositorycache.New(baseRepo, cacheService, &CustomKeySerializer{
prefix: "myapp:v1",
})With in memory caching:
- Cache hits: ~100-500 nanoseconds
- Database queries: ~1-50 milliseconds
- Improvement: 10,000x - 100,000x faster for repeated reads
Additional benefits:
- Reduced database load
- Improved application response times
- Built in stampede protection prevents cache dogpiling
Write operations automatically invalidate cached reads when the cache service
implements cache.TagRegistry (the default sturdyc adapter does). The decorator
registers read keys under scope, ID, identifier, and list tags, then invalidates
those tags after creates/updates/deletes.
If TagRegistry is not available, the decorator falls back to prefix deletion for
List/Count/Get caches.
Need custom grouping? Attach extra tags to any read path:
ctx := repositorycache.WithCacheTags(ctx, "preferences:tenant:"+tenantID)
prefs, total, err := cachedRepo.List(ctx, repository.Where("tenant_id", tenantID))See cmd/app/main.go for a full working example that demonstrates:
- Setting up the cache infrastructure
- Wrapping a fake repository
- Showing cache hits vs misses
- Performance measurement
Run the example:
go run cmd/app/main.gorepositorycache/required_scope_test.go exercises the real Bun repository on
disposable SQLite with the production cache adapter and default key serializer.
It warms Group A reads, proves repeated reads avoid SQL, then verifies missing,
wrong and unregistered scope denial plus independent Group B results. Different
Group sizes also detect leaked List totals and Count values. Both constructor
scope defaults and defaults set through the decorator are covered.
TestCachedRepository_RequiredScopeCriteriaBypassWarmCache checks that repeated
criteria reads reach SQL despite warm entries, restrictive predicates match no
rows, and ordinary OR predicates cannot widen the required Group boundary.
Criteria reads preserve the original value-only cache entry.
go test ./...
go test -race -count=1 -run '^TestCachedRepository_RequiredScope' ./repositorycacheThese tests verify SQLite and in-process cache behavior; they do not establish PostgreSQL behavior or cache coherence after out-of-band policy/configuration changes.
func TestCachedRepository(t *testing.T) {
// Use an in memory cache for testing
config := cache.Config{TTL: 5 * time.Minute}
container, err := di.NewContainer(config)
if err != nil {
t.Fatalf("failed to create container: %v", err)
}
baseRepo := &mockUserRepository{}
cachedRepo := container.NewCachedRepository(baseRepo)
// test cache behavior
user1, _ := cachedRepo.GetByID(ctx, "123") // Calls base repo
user2, _ := cachedRepo.GetByID(ctx, "123") // Uses cache
assert.Equal(t, user1, user2)
assert.Equal(t, 1, baseRepo.CallCount) // Only one DB call
}This project is licensed under the MIT License, see the LICENSE file for details.
- go-repository-bun: The base repository interface
- sturdyc: The underlying cache library
- uptrace/bun: The SQL client