If your backend is not among the available drivers, you can implement its service contract in your own Go module. A driver translates that contract into backend operations; the facade continues to handle typed application calls and its ordinary events and telemetry.
A wrapper is a driver too. It forwards to another implementation while changing one responsibility, such as key naming. Neither kind requires an Arkoren checkout or a contribution to the toolkit.
Contract First
Start with the public interface and its method comments. For the cache example,
inspect cache.Driver:
go doc arkoren.dev/core/service/cache.Driver
The contract describes missing values, expiry, cancellation, byte ownership, and atomic operations. Its test package checks many of those rules, but tests are evidence of covered behavior, not a replacement for the contract.
Decide whether you need a whole driver before implementing one. If an existing driver already handles ordinary calls and you only need a provider-specific operation, use its native client access. For cache naming alone, typed keys and namespaces can avoid a wrapper. A wrapper is useful when an existing raw-driver integration needs the same naming rule applied to every operation.
Wrap Existing Behavior
Suppose your application needs a fixed prefix on a raw cache connection. Create
an internal/prefixcache package in your application module. The following
fragments use context and arkoren.dev/core/service/cache; they illustrate
selected methods, not a complete cache.Driver implementation.
Keep the underlying driver private rather than embedding it. Embedding would silently forward methods you forgot to adapt, potentially writing unprefixed keys. A constructor with no optional settings needs no construction builder:
type Driver struct {
next cache.Driver
prefix string
}
func New(next cache.Driver, prefix string) *Driver {
return &Driver{next: next, prefix: prefix}
}
This wrapper borrows next: its caller owns that driver’s startup and shutdown.
Choose a complete, unambiguous prefix such as reports:tenant-42: and encode
variable identities consistently. Prefixing is not authorization or isolation
from code that can access the underlying driver.
For reads, change the key and leave the returned value and error semantics to the underlying implementation:
func (driver *Driver) Get(ctx context.Context, key string) ([]byte, error) {
return driver.next.Get(ctx, driver.prefix+key)
}
Forward every interface method, applying the same prefix to every key or lock
name. Keep values, lock tokens, durations, and counter deltas unchanged. In
particular, IncrementWithTTL must use the prefixed identity too, or counter
windows would disagree with ordinary reads.
Guarded Writes
A guarded write can compare one key and replace another. Transform both keys before forwarding the single atomic operation:
func (driver *Driver) CompareAndPut(ctx context.Context, match cache.Match, entry cache.Entry) (bool, error) {
match.Key = driver.prefix + match.Key
entry.Key = driver.prefix + entry.Key
return driver.next.CompareAndPut(ctx, match, entry)
}
The records are passed by value, so changing their key fields does not change
the caller’s records. A mismatch returns false, nil without changing either
entry or its TTL. Do not replace the call with a read followed by a write:
another writer could change the guard between them. Preserve ErrUnsupported
when the underlying driver cannot provide the required atomicity, and preserve
uncertain transport errors rather than falling back to an unconditional write.
Guarded Writes shows the application use.
Native Access
Expose the wrapped contract through Unwrap so the facade’s DriverAs helper
can search through your wrapper:
func (driver *Driver) Unwrap() cache.Driver {
return driver.next
}
Native calls bypass your prefix transformation and the facade’s operation instrumentation. Give callers that escape hatch deliberately, not as a promise that native operations obey wrapper policy.
After implementing all methods, add a compile-time interface check:
var _ cache.Driver = (*Driver)(nil)
This catches missing methods and incorrect signatures. It cannot detect a forgotten prefix or a non-atomic implementation; those need behavioral tests.
Your application can then pass the completed wrapper to the ordinary facade.
With an existing backend named backend whose lifecycle you already own:
store := cache.New(prefixcache.New(backend, "reports:tenant-42:"))
Facade calls now use the wrapper’s naming rule without changing application
key definitions. Keep the wrapper chain acyclic so DriverAs can finish its
search.
Adapt a Backend
A backend driver performs the same contract operations through a vendor client. Preserve distinctions that affect recovery: missing data is not an outage, a rejected comparison is not a failed request, and a request error does not always prove the backend performed no write.
For example, a Redis-backed Get can map native absence to cache.ErrNotFound
without hiding other failures. With a private client *redis.Client field,
import errors and github.com/redis/go-redis/v9 in addition to the cache and
context packages:
func (driver *Driver) Get(ctx context.Context, key string) ([]byte, error) {
value, err := driver.client.Get(ctx, key).Bytes()
if errors.Is(err, redis.Nil) {
return nil, cache.ErrNotFound
}
return value, err
}
Callers can distinguish absence with errors.Is. A connection failure remains
a connection failure. If you wrap errors for context, preserve their identity
with %w; never include credentials or cached contents in the message.
The remaining implementation must satisfy the whole interface. For cache, that includes independent byte copies, concurrent calls, cancellation before mutation, nonmutating integer overflow, and atomic counters, pulls, and lock ownership checks. Backend transactions, scripts, or native atomic commands may be necessary. An unsupported result is valid only where the contract permits it; do not advertise a capability your backend cannot implement safely.
Construction and Ownership
Keep configuration separate from runtime operations. Put required values with
no safe default in constructor arguments. For optional settings, expose a
construction draft named Builder and its matching alias:
type BuilderFunc = func(*Builder)
Use New(required..., configure ...BuilderFunc). Invoke supplied callbacks
directly, synchronously, once in order on one fresh draft before ordinary
validation. A supplied nil callback panics; an expanded nil or empty slice adds
no edits. Draft methods return that draft. Publish detached private settings so
a retained draft or input container cannot reconfigure the driver. Native
clients, functions, and their captures need explicit ownership rules rather
than a claim of recursive copying.
The shipped Redis cache driver demonstrates this public shape. Import
arkoren.dev/driver/redis/rediscache, installed as described in
Cache Drivers:
var connection rediscache.BuilderFunc = func(store *rediscache.Builder) {
store.Addr("cache.internal:6379")
}
backend := rediscache.New(connection)
This constructs a driver without contacting Redis. Start(ctx) checks
connectivity, Ping(ctx) provides a readiness check, and Stop(ctx) closes its
client. Your application registers lifecycle work; the driver does not register
itself. Lifecycle and Ownership
explains the wiring.
Native Resources
If you offer NewFrom(client), document whether it borrows or takes ownership
and which optional settings still apply. Settings-free conversions need no
callback. Do not infer lifetime from the constructor name: Redis cache
NewFrom transfers ownership and Stop closes the supplied client, whereas
Redis broadcasting borrows its supplied client.
Configure native connection policy before sharing or transferring a client.
An accessor such as Client(), or DB()/SQL() for database drivers, exposes
native operations without transferring ownership. Explain when that accessor
is usable and whether native calls bypass facade or transaction behavior.
Acquire connections and start background loops at the documented lifecycle boundary, not incidentally in ordinary configuration callbacks. Cloud SDK configuration and credential loading can still require a constructor context. Release owned resources even if the stop context has expired; document whether shutdown waits for active work and how long the caller controls that wait. Borrowing a shared resource must not let one driver close it behind another.
Keep credentials out of diagnostic output. Redact any exported settings record
in its String, GoString, and slog.LogValuer implementations, and avoid
logging native clients, payloads, or raw configuration. Redacting your record
does not sanitize native errors or caller-owned SDK objects.
Schema Ownership
If the driver needs database tables, expose Migrations() for the application’s
migrator. Do not run DDL during construction, startup, or the first operation.
The application applies schema changes through its
migration deployment process. Readiness can report
missing tables without trying to create them.
Check the Contract
Once the prefix wrapper implements the complete interface, run the cache suite
against it over a fresh memory driver. In your application’s wrapper test,
import testing, the cache package and its cachetest and memorycache
subpackages, plus your own internal/prefixcache package:
func TestPrefixContract(t *testing.T) {
cachetest.Run(t, func(t *testing.T) cache.Driver {
return prefixcache.New(memorycache.New(), "reports:tenant-42:")
})
}
The cache suite creates drivers per subtest and runs cases in parallel. This
memory driver needs no Start for reads or expiry, and no janitor is started
here. Add wrapper-specific tests that inspect underlying keys: a consistently
unprefixed wrapper could pass the portable suite while failing its own purpose.
For a vendor driver, initialize the required backend in the factory and register
cleanup on its supplied *testing.T before returning. Do not defer shutdown
inside the factory: it would close the driver before the suite uses it. If the
backend controls expiry, call the suite with cachetest.WithExternalClock().
Use isolated disposable resources for integration tests.
Other suites have different factory shapes, lifecycle needs, and capability options. Follow Driver Conformance, especially its coverage limits, before treating a pass as evidence. Test backend failures and application-specific behavior separately; suite options do not relax required semantics.
For the interface and test entry points used here, see the
cache API and
cachetest API.