Forge Cache is the caching extension for Forge, the Go backend framework. You add it to your app and get a key-value cache with per-key TTLs, a default expiry, key prefixes for namespacing, glob-style key lookup and size limits, registered in the dependency injection container so any service can ask for it. Today the in-memory driver is the one that works. The config also accepts redis and memcached, but those drivers aren't implemented yet, so read Drivers before you plan around them.
Documentation · Quick start · Features · Using the cache · Drivers · Configuration · FAQ
You need Go 1.26 or later and a Forge app. Add the extension module:
go get github.com/xraph/forge@latest
go get github.com/xraph/forge/extensions/cache@latestSave this as main.go:
package main
import (
"errors"
"log"
"net/http"
"time"
"github.com/xraph/forge"
"github.com/xraph/forge/extensions/cache"
)
func main() {
app := forge.NewApp(forge.AppConfig{
Name: "cache-demo",
HTTPAddress: ":8080",
Extensions: []forge.Extension{
cache.NewExtension(
cache.WithDefaultTTL(10*time.Minute),
cache.WithPrefix("demo:"),
),
},
})
err := app.Router().GET("/greeting", func(ctx forge.Context) error {
c, err := cache.GetCache(app.Container())
if err != nil {
return err
}
value, err := c.Get(ctx.Context(), "greeting")
if errors.Is(err, cache.ErrNotFound) {
value = []byte("Hello from the cache")
if err := c.Set(ctx.Context(), "greeting", value, 30*time.Second); err != nil {
return err
}
return ctx.JSON(http.StatusOK, map[string]string{"source": "miss", "value": string(value)})
}
if err != nil {
return err
}
return ctx.JSON(http.StatusOK, map[string]string{"source": "hit", "value": string(value)})
})
if err != nil {
log.Fatal(err)
}
if err := app.Run(); err != nil {
log.Fatal(err)
}
}Run it and call the route twice:
go mod tidy
go run .
curl http://localhost:8080/greeting
# {"source":"miss","value":"Hello from the cache"}
curl http://localhost:8080/greeting
# {"source":"hit","value":"Hello from the cache"}The second call comes from memory. Wait thirty seconds and you get a miss again, because the handler wrote that key with its own 30-second TTL and that wins over the ten-minute default you set on the extension. When you stop the app with Ctrl+C, Forge stops the cache service along with everything else, and the in-memory store is emptied.
| You need | Forge Cache gives you |
|---|---|
| A cache any service can inject | A cache.Cache interface and a *cache.CacheService registered in the Vessel container, also under the name "cache" |
| Entries that expire on their own | A TTL per Set, a DefaultTTL for Set calls that pass 0, plus TTL and Expire to read and change it later |
| Several apps or tenants sharing a key space | A Prefix added to every key on write and removed again from Keys results |
| Bounded memory use | MaxSize (entries), MaxKeySize and MaxValueSize (bytes), checked on every Set |
| Finding keys by pattern | Keys(ctx, "user:*") with * wildcards |
| Strings and structs without hand-written encoding | StringCache and JSONCache helpers on the in-memory backend |
| To know it's working | Hit, miss, set, delete, clear and eviction counters through Forge metrics, and a Health method on the service that pings the backend |
The extension registers a constructor, so you can take cache.Cache as a parameter like any other dependency:
type UserService struct {
cache cache.Cache
}
func NewUserService(c cache.Cache) *UserService {
return &UserService{cache: c}
}
// In your setup code, after forge.NewApp:
err := forge.Provide(app.Container(), NewUserService)If you'd rather pull it out yourself, cache.GetCache(container), cache.GetCacheFromApp(app) and forge.Inject[cache.Cache](container) all return the same singleton. MustGetCache and MustGetCacheFromApp panic when it isn't registered.
Every value is a []byte. These methods are on cache.Cache:
| Method | What it does |
|---|---|
Get(ctx, key) |
Returns the value, or cache.ErrNotFound when the key is missing or expired |
Set(ctx, key, value, ttl) |
Stores a copy of value. A ttl of 0 uses DefaultTTL |
Delete(ctx, key) |
Removes a key. Deleting a missing key is not an error |
Exists(ctx, key) |
Reports whether a live entry exists |
Keys(ctx, pattern) |
Lists live keys matching a glob such as *, user:*, *:draft or user:*:profile |
TTL(ctx, key) |
Returns the time left, or 0 for an entry that never expires |
Expire(ctx, key, ttl) |
Sets a new TTL. A ttl of 0 or less makes the entry permanent |
Clear(ctx) |
Empties the whole cache |
Ping(ctx) |
Returns cache.ErrNotConnected until the service has started |
Connect and Disconnect are on the interface too. You don't call them: the extension does that for you during start and stop.
Two things to know about Keys. It supports one * per pattern, and a pattern with two wildcards matches nothing. It also walks every entry while holding the read lock, so if your cache is large you should keep it off hot request paths. A typical cleanup looks like this:
keys, err := c.Keys(ctx, "session:*")
if err != nil {
return err
}
for _, k := range keys {
if err := c.Delete(ctx, k); err != nil {
return err
}
}The in-memory backend also implements cache.StringCache (GetString, SetString) and cache.JSONCache (GetJSON, SetJSON). The cache.Cache you get from the container doesn't expose them directly, so ask for the service and type-assert its backend:
svc, err := forge.Inject[*cache.CacheService](app.Container())
if err != nil {
return err
}
jc, ok := svc.Cache().(cache.JSONCache)
if !ok {
return errors.New("backend does not support JSON helpers")
}
if err := jc.SetJSON(ctx, "user:42", User{ID: "42", Name: "Ada"}, time.Hour); err != nil {
return err
}
var u User
err = jc.GetJSON(ctx, "user:42", &u)cache.CounterCache (Incr, DecrBy and friends) and cache.MultiCache (MGet, MSet, MDelete) are declared in the package, but no backend implements them yet.
| Error | When you'll see it |
|---|---|
cache.ErrNotFound |
Get, TTL or Expire on a key that is missing or expired |
cache.ErrNotConnected |
Any call before the app has started the cache, or after it stopped |
cache.ErrKeyTooLarge |
A key longer than MaxKeySize bytes |
cache.ErrValueTooLarge |
A value larger than MaxValueSize bytes |
cache.ErrInvalidTTL |
A negative DefaultTTL in the config |
Compare them with errors.Is. An empty key on Set returns a plain error.
| Driver | Status |
|---|---|
inmemory |
Works. A map guarded by a read-write mutex, with a background sweep every CleanupInterval that removes expired entries |
redis |
Accepted by config validation (it needs a URL), but service construction fails with "redis driver not yet implemented" |
memcached |
Same as redis: validated, then construction fails with "memcached driver not yet implemented" |
So for now the cache lives in one process. If you run two replicas of your service, each has its own copy, and a restart empties it.
When the in-memory cache holds MaxSize entries, the next Set removes one entry first. It picks an expired entry if it finds one, and otherwise an arbitrary entry in Go map order. That is not LRU. If you need hot keys to survive, size MaxSize so you rarely reach it.
You can configure the extension in code with options, or in YAML under extensions.cache (the extension also reads a top-level cache key).
| Option | Sets |
|---|---|
WithDriver(string) |
Driver: inmemory, redis or memcached |
WithURL(string) |
URL, required for redis and memcached |
WithDefaultTTL(time.Duration) |
DefaultTTL |
WithMaxSize(int) |
MaxSize |
WithPrefix(string) |
Prefix |
WithConnectionPoolSize(int) |
ConnectionPoolSize |
WithRequireConfig(bool) |
RequireConfig: fail at startup if no YAML config is found |
WithConfig(cache.Config) |
Replaces the whole config |
NewExtensionWithConfig(cfg) is the same as NewExtension(WithConfig(cfg)).
| Field | Default (DefaultConfig()) |
Notes |
|---|---|---|
Driver |
inmemory |
|
URL |
empty | |
DefaultTTL |
5m |
Used when Set gets a TTL of 0 |
MaxSize |
10000 |
Entries. 0 means no limit |
CleanupInterval |
1m |
How often expired entries are swept |
MaxKeySize |
250 |
Bytes. 0 means no limit |
MaxValueSize |
1048576 |
Bytes (1 MiB). 0 means no limit |
Prefix |
empty | |
ConnectionPoolSize |
10 |
Reserved for network drivers |
ConnectionTimeout, ReadTimeout, WriteTimeout |
5s, 3s, 3s |
Reserved for network drivers |
RequireConfig |
false |
The keys are the snake_case field names:
extensions:
cache:
driver: inmemory
default_ttl: 10m
max_size: 50000
prefix: "orders:"If your file still says MaxSize or DefaultTTL, it keeps working, and you'll get a warning at startup naming the snake_case key to switch to.
A plain cache.NewExtension() picks up every key in that block. YAML overrides DefaultConfig() and options you set in code override YAML, and fields you leave out of the file keep their defaults.
Forge reads config.yaml only when EnableConfigAutoDiscovery is on. forge.DefaultAppConfig() turns it on, but an AppConfig literal like the one in the quick start leaves it off, so set it yourself or pass your own ConfigManager.
| Topic | Where |
|---|---|
| Features and usage | Cache features |
| Configuration reference | Cache configuration |
| Function reference | Cache functions |
| Go API | pkg.go.dev |
| How extensions load config and register services | Forge extension system |
The docs pages were written before this README, so where they disagree about drivers or config keys, the tables above match the code.
Add cache.NewExtension() to Extensions in forge.AppConfig, then inject cache.Cache wherever you need it. The quick start is a complete program.
Not yet. Both driver names pass config validation, but the service returns a "not yet implemented" error when it's constructed, so the app fails to start. Use inmemory until a network driver lands.
Yes. All reads and writes go through a sync.RWMutex, and the extension's tests run concurrent goroutines against it.
When it holds MaxSize entries, the next Set removes one expired entry, or an arbitrary entry if none has expired, and then stores the new one. Set MaxSize to 0 for no limit.
Yes, through JSONCache.SetJSON and GetJSON on the backend, as shown in Strings and JSON. Otherwise you encode to []byte yourself.
The cache is its own Go module inside the Forge repository. Build, vet and test it from its directory the way CI does:
cd extensions/cache
GOWORK=off go build ./...
GOWORK=off go vet ./...
GOWORK=off go test -short -count=1 ./...
golangci-lint run ./...Forge's AGENTS.md has the pinned lint version and the rest of the repository conventions. Commits follow Conventional Commits, for example fix(cache): ....
Forge Cache is part of Forge and is available under the Apache License 2.0.