Custom Data Store¶
A Data Store is engine-global, item-aware data that outlives a Process
instance and is shared across instances within the running engine (BPMN
§10.4.1). The engine ships a non-durable in-memory adapter (memstore); to make
that data survive a restart — or share it across a cluster — you implement the
datastore.DataStore seam over your own backend (Redis, Postgres, an object
store) and register it on the engine. This page is the extension reference: the
interface you implement, the registration call, a minimal real adapter, and how
the engine drives it.
The DataStore contract¶
A store is a named bag of data.Data addressed by an opaque key. You implement
four methods:
type DataStore interface {
// Get returns the datum stored under name; the bool is false when none
// exists.
Get(ctx context.Context, name string) (data.Data, bool, error)
// Put stores (or replaces) d under name.
Put(ctx context.Context, name string, d data.Data) error
// Capacity reports the store's nominal item capacity (BPMN §10.4.1). It is
// advisory in the in-memory adapter (ADR-030 §2.6) — a durable adapter may
// enforce it.
Capacity() int
// IsUnlimited reports whether the store has no capacity bound.
IsUnlimited() bool
}
| Member | You implement | Contract |
|---|---|---|
Get(ctx, name) |
the read path | return (datum, true, nil) on a hit; (nil, false, nil) on a miss — a miss is not an error. |
Put(ctx, name, d) |
the write path | store or replace d under name. |
Capacity() |
the nominal item bound | advisory in memstore; a durable adapter may enforce it by rejecting a Put past the bound. |
IsUnlimited() |
whether a bound exists | true when the store has no capacity limit. |
The value you store and return is a
data.Data— the same item-aware type aProperty,Parameter, orDataObjectcarries (build one withdata.NewPathDataor hand back what you received). Your adapter persists and restores that item, not a raw scalar.
Two properties the engine relies on: a miss is a clean false, never an
error (an unknown key is a normal query, not a fault); and a store is its own
namespace — distinct dataStoreRefs never share state, so one adapter
instance backs exactly one ref.
The reference implementation¶
The built-in memstore adapter is the canonical implementation — read it before
writing your own:
| Type | Role |
|---|---|
memstore.Store |
a concurrency-safe, in-memory datastore.DataStore (memstore.New(opts…)). |
memstore.WithCapacity(n) |
set the nominal capacity (advisory; n <= 0 leaves it Unlimited). |
memstore.Registry |
the default datastore.Registry — a set of named stores (NewRegistry, Register, Store). |
memstore.New() returns an unbounded store; your adapter mirrors its shape —
Get/Put/Capacity/IsUnlimited over whatever backend you choose.
Registration¶
Register each store on the engine with the thresher.WithDataStore option — the
same seam the built-in memstore uses, so your adapter drops straight in:
func WithDataStore(ref string, store datastore.DataStore) Option
| Parameter | Meaning |
|---|---|
ref |
the dataStoreRef a DataStoreReference reads and writes (its BPMN key). |
store |
any datastore.DataStore — the in-memory memstore, or your durable adapter. |
Call it once per distinct store; registering an already-used ref replaces
it. There is no SetGenerator/adapters.Register-style global — a store is
per-engine, wired at construction:
eng, err := thresher.New("orders-engine",
thresher.WithDataStore("shared", myredis.New(client, "gobpm:")))
A minimal adapter¶
The whole seam is four methods. A read-through wrapper that logs every access,
delegating storage to any inner datastore.DataStore:
type auditStore struct {
inner datastore.DataStore
log *slog.Logger
}
func (a *auditStore) Get(
ctx context.Context, name string,
) (data.Data, bool, error) {
d, ok, err := a.inner.Get(ctx, name)
a.log.Info("datastore get", "name", name, "hit", ok)
return d, ok, err
}
func (a *auditStore) Put(ctx context.Context, name string, d data.Data) error {
a.log.Info("datastore put", "name", name)
return a.inner.Put(ctx, name, d)
}
func (a *auditStore) Capacity() int { return a.inner.Capacity() }
func (a *auditStore) IsUnlimited() bool { return a.inner.IsUnlimited() }
Register it like any store:
store := &auditStore{inner: memstore.New(), log: slog.Default()}
eng, _ := thresher.New("audited", thresher.WithDataStore("shared", store))
How the engine uses it¶
A DataStoreReference in a process names a store by dataStoreRef; the engine
resolves it against the registered stores and calls your Put/Get as the
reference is written or read:
- a
DataOutputAssociation(Node → DataStore) drivesPut— the task's output lands in your store under the reference's key; - a
DataInputAssociation(DataStore → Node) drivesGet— the store's value flows into a later task's input, in a different instance.
Because the store lives on the engine, not the instance, the value crosses
instance boundaries. Running examples/data-store/ — a writer instance stores
42, a separate reader instance reads it back through the same "shared" store:
✓ writer instance stored 42 in DataStore "shared" key "counter"
✓ reader instance read 42 from the shared DataStore
✓ data-store demo: the value outlived the writer instance and crossed into the reader through the engine-global store
Reach for a custom Data Store when the built-in memstore isn't enough:
you need the shared data to survive an engine restart (a durable backend),
to be shared across a cluster of engine processes, or to be governed by an
external system of record. If the data is per-instance and scope-resident, you
want a Data Object, not a Data Store; if it's
engine-global but ephemeral is fine, the default memstore already covers you —
see Data Store.
See also¶
- Examples:
examples/data-store/ - Related guides: Data Store · Data Objects · Custom repository
- Design: ADR-030 — Data Objects and Store
- Full API:
go doc github.com/dr-dobermann/gobpm/pkg/datastore