LOG // Type-Erasure Dispatch Pattern in Go
Coordinators tend to rot when they need to manage many concrete types. You start with a small workflow, then add one type switch, then another, and eventually every new backend or model change touches orchestration code.
The type-erasure dispatch pattern is how I got out of this in a multi-database sync system.
The Scaling Failure That Forced the Refactor
Originally I had 6 different database variations to support behind one sync workflow. The design tied capabilities directly to typed interfaces and backend implementations.
Every time I added a new structure:
- I had to update the central interface
- then update all 6 databases to match
At that point I still had 3 more databases planned and about 5 more capabilities to add. The growth path was not maintainable. The surface area was exploding as a cross-product of databases and capabilities, and orchestration kept getting harder to reason about.
Core Roles
Capability ID
A stable string/enum key (for example:page,template,product).Item Contract
A small type-erased interface for all syncable records (for example:Key(),Checksum(),ModifiedAt()).Typed Repository
Strongly-typed data access for one item type (Repo[T]).Capability Repository
A runtime, type-erased repository contract used by generic workflows.Adapter Backend
A concrete backend that advertises supported capabilities and implements typed operations.Capability Wrapper/Bridge
Adapts typed operations into the type-erased capability repository.Adapter Facade
Holdsmap[capability]CapabilityRepoand resolves repos by capability.Orchestrator
Runs generic operations (push,pull,sync) by:
- negotiating shared capabilities
- dispatching each capability to resolved repos
- applying conflict/dirtiness rules consistently
Reference Shape (Pseudocode)
type RepoItem interface {
Key() string
Checksum() string
StoredChecksum() string
ModifiedAt() time.Time
}
type Repo[T RepoItem] interface {
List(ctx context.Context) ([]T, error)
Read(ctx context.Context, key string) (*T, error)
Upsert(ctx context.Context, item *T) error
}
type CapabilityRepo interface {
Capability() string
List(ctx context.Context) ([]RepoItem, error)
Read(ctx context.Context, key string) (RepoItem, error)
Upsert(ctx context.Context, item RepoItem) error
DirtyKeys(ctx context.Context) (map[string]struct{}, error)
MarkClean(ctx context.Context, items []RepoItem) error
}
type Backend interface {
Capabilities() []string
GetCapability(capability string) CapabilityRepo
// typed operations...
}
Runtime Flow
- A backend declares supported capabilities.
- An adapter facade builds a capability-to-repo map.
- The orchestrator intersects capability sets between source and target.
- For each shared capability:
- resolve local and remote capability repos
- execute a generic operation (
list/read/upsert) - apply common policy (dirty-only push, conflict resolution, checksum state updates)
- Aggregate per-item/per-capability errors into one operation result.
Why This Works
- Generic logic stays generic.
- Backend differences are isolated behind capability resolution.
- Feature availability is explicit and discoverable at runtime.
- Adding a new capability is incremental instead of invasive.
Extension Workflow
- Add a new capability identifier.
- Define the item type to satisfy the base item contract.
- Implement a typed repo for each backend that should support it.
- Register/wrap each typed repo into the capability map.
- Optionally add orchestration order, labels, and mode-specific policies.
- Add tests for:
- unsupported capability behavior
- dispatch correctness
- sync behavior and conflict policy
Error Model
Recommended error categories:
ErrNoCapability: requested capability is not supportedErrReadonly: write attempted on read-only targetErrUnsupportedMode: operation mode not available for this capability
The key win is that I no longer edit orchestration when adding every new concrete type. I add capabilities and backend support incrementally, and the dispatcher handles the rest.