Skip to content
Zever
Home

Write a plugin

Add a new adapter to an existing battery or ship an entirely new battery as a standalone Go module.

4 min readEdit on GitHub

A plugin is a standalone Go module: a new adapter for an existing battery (zever-adapter-<name>), or an entirely new battery (zever-battery-<name>, dogfooded by examples/sms). Run the conformance kit before publishing.

A battery owns an interface plus a Register/Open pair. The container is the only resolver; adapters never self-register (no init, no globals). Each adapter lives at adapters/<battery>/<name> and exposes a constructor plus Register(). See adapters/cache/memory/register.go.

Adapters are plain strings (type Adapter string, such as cache.Memory = "memory"). ParseAdapter accepts any non-empty name, so a custom backend round-trips through file and env config; String() is the identity ("unknown" only for empty).

This cache adapter lives in module zever-adapter-foo:

foo/foo.go
package foo // import "example.com/zever-adapter-foo"
import (
"context"
"time"
"github.com/zenta-dev/zever/core/cache"
)
// driver implements all 8 cache.Cache methods.
type driver struct{ /* connection state */ }
func (d *driver) Get(ctx context.Context, key string) ([]byte, error) { panic("implement") }
func (d *driver) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
panic("implement")
}
func (d *driver) SetIfAbsent(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error) {
panic("implement")
}
func (d *driver) Delete(ctx context.Context, key string) error { panic("implement") }
func (d *driver) Increment(ctx context.Context, key string) error { panic("implement") }
func (d *driver) Decrement(ctx context.Context, key string) error { panic("implement") }
func (d *driver) Exists(ctx context.Context, key string) (bool, error) { panic("implement") }
func (d *driver) Close(ctx context.Context) error { panic("implement") }
// New matches cache.Factory.
func New(opts cache.Options) (cache.Cache, error) {
return &driver{}, nil
}

The host (your app, not the adapter package) registers it:

main.go
const fooAdapter = cache.Adapter("foo")
if err := cache.Register(fooAdapter, foo.New); err != nil {
return err
}
c, err := cache.Open(fooAdapter, cache.Options{})

With the container, set cache: {adapter: foo} (or CACHE_ADAPTER=foo) and the first container.Cache() call resolves your factory like any core adapter.

Follow these rules:

  • Do not add methods to a public battery interface; propose a new capability interface instead (such as cache.CompareAndSwapCache).
  • Call opts.Validate() first in Open; fail closed and join all violations.
  • Keep errors sentinel-based, errors.Is-compatible, prefixed with the battery name (cache:); never put secrets or raw option maps in error text (use config.RedactedServices).
  • Take ctx first for I/O and never store it in a struct.

Every battery with a kit exposes Conformance in core/<b>/<b>test. Run it in your tests and link the passing run from your README:

foo/foo_test.go
import "github.com/zenta-dev/zever/core/cache/cachetest"
func TestConformance(t *testing.T) {
t.Parallel()
cachetest.Conformance(t, func(t *testing.T) cache.Cache {
t.Helper()
c, err := foo.New(cache.Options{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = c.Close(t.Context()) })
return c
})
}

Kits exist for these batteries (import github.com/zenta-dev/zever/core/<b>/<b>test):

ai analytics auth authz billing cache crypto db document eventbus flag geo i18n
idempotency job lock log mailer media middleware notification observability
outbox password payment permission queue ratelimit resilience router scheduler
search secrets session storage tenant vectorstore webhook workflow

Mirror the core shape: an interface, a string Adapter, typed Options with Validate, a Register/Open pair, and sentinel errors. Copy examples/sms/sms.go (the SMS interface, Stub adapter, Options{From}, Register, Open) and add a deterministic stub backend like examples/sms/stub.

  1. Add the config entry. Plugins live under config.Config.Plugins, keyed by name with raw options the plugin validates itself (map[string]Service[json.RawMessage], nil by default, so initialize it first):

    cfg.Plugins = map[string]config.Service[json.RawMessage]{
    "sms": {
    Adapter: "stub",
    Options: json.RawMessage(`{"from":"+15550000"}`),
    },
    }
  2. Register a validator so Config.Validate covers the plugin. Plugins without one skip validation and their options stay opaque. <PLUGIN>_ADAPTER (for example SMS_ADAPTER=stub) overrides the adapter from the environment; options stay raw and are not env-overridable:

    if err := config.RegisterPluginValidator("sms", func(adapter string, opts json.RawMessage) error {
    var o sms.Options
    if err := json.Unmarshal(opts, &o); err != nil {
    return err
    }
    return o.Validate()
    }); err != nil {
    return err
    }
  3. Register a versioned build func once at startup, then resolve per container. The build receives the container’s *config.Config:

    if err := container.RegisterPlugin[sms.SMS]("sms", container.PluginAPIVersion, func(cfg *config.Config) (sms.SMS, error) {
    var opts sms.Options
    if err := json.Unmarshal(cfg.Plugins["sms"].Options, &opts); err != nil {
    return nil, err
    }
    return sms.Open(sms.Stub, opts)
    }); err != nil {
    return err
    }
    c := container.New(cfg)
    svc, err := container.Resolve[sms.SMS](c, "sms")

Successful builds cache per Container; failures retry on the next Resolve. A wrong-typed value fails Resolve with a type-mismatch error, and resolved plugins close with Container.Close. examples/sms/app/main.go shows the full flow.

zever add <battery>[/<adapter>] scaffolds the go.mod, internal/app/wiring.go with typed container.Provide wiring. For a plugin, pass an explicit adapter and --module <path[@version]> (default v0.1.0):

Terminal window
zever add sms/stub --module example.com/zever-battery-sms

Flags are --adapter, --force, --module, and --dry-run. Run go mod tidy afterwards. See the CLI reference.

There is no manifest file: the go.mod requires are the component list, and the generated app.go imports plus Register() calls are the manifest, emitted for the floor (log, router) plus your picks.

This mirrors the database/sql driver model: import alone resolves nothing, and registration without the go.mod require does not build. For every adapter, import its module, call Register() before first use, and require it in go.mod. An unregistered adapter fails resolution with UnknownAdapterError.

container.PluginAPIVersion is one integer, currently 1, bumped only on a breaking host-side change. RegisterPlugin rejects any other version, empty names, nil builds, and duplicate names, so mismatches fail at registration, never resolve. The pre-1.0 0.x core may break the plugin surface with release notes. Pin scaffolds with --framework-version.

  • Publish from your own repo with independent semver tags (not a lockstep Zever tag).
  • State the targeted PluginAPIVersion in your README and verify a mismatched RegisterPlugin fails before publishing.
  • Link the passing conformance run from your README.
  • Record changes in your own CHANGELOG.md.

.zen schemas cannot reference plugin batteries. This is a scope boundary: the compiler resolves only core batteries (the job queue: name is never validated against registered adapters), while plugins wire at runtime. Model the domain in .zen, then wire the plugin in Go. Syntax: Zen syntax.

Why there is no schema-level plugin reference yet

A proposal (uses sms; plus an optional @via(sms) field attribute) was deferred:

  • A, uses declaration: the resolver records and checks names at runtime. It touches the token table (uses becomes reserved), AST, parser, resolver and IR, formatter, breaking, grammars, LSP, and fixtures, plus a second manifest.
  • B, plugin-shipped .zen fragment: reuses existing machinery, but nothing machine-checks the fragment against the Go code, and there is no import.
  • C, typed registry in the resolver: rejected; dsl/ must never import runtime code, and it adds global state.

Revisit A when a real plugin needs schema-visible surface (validation, OpenAPI, queue typing). Never choose C.

Why idempotency, lock, and ratelimit do not wrap cache

None maps cleanly onto cache reads and writes: idempotency.Store uses a Begin/Complete/Forget reservation protocol, lock.Locker issues holder-scoped leases with ownership-checked Extend/Unlock, and ratelimit.Limiter does token-bucket accounting returning Decision{Allowed, RetryAfter, Remaining}. The reuse boundary is shared primitives (shared/cas, shared/redisclient).