graft

package module
v0.1.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 29, 2025 License: MIT Imports: 10 Imported by: 2

README

graft

Go Reference Go Report Card Examples

Graph-based dependency execution for Go. Nodes declare dependencies explicitly; the engine executes them in topological order with automatic parallelization.

Features

  • Type-safe nodes — Generic Node[T] with compile-time type checking
  • Declarative dependencies — Nodes specify what they need, not how to get it
  • Automatic parallelization — Independent nodes run concurrently
  • Subgraph execution — Run only specific nodes and their transitive dependencies
  • Node-level caching — Cache expensive nodes across executions
  • Static analysis — Validate dependency declarations at test time

Install

go get github.com/grindlemire/graft

Usage

Define Nodes

Each node is typically its own package with an init() that registers it.

Suppose we need to load configuration to access our database:

// nodes/config/config.go
package config

import (
    "context"
    "github.com/grindlemire/graft"
)

// The ID of the node in the engine
const ID graft.ID = "config"

// The output type that other nodes will access
type Output struct {
    DBHost string
    Port   int
}

// init registers the node automatically on startup
func init() {
    graft.Register(graft.Node[Output]{
        ID:        ID,
        DependsOn: []graft.ID{}, // root node
        Run:       run,
    })
}

// run is executed by the engine 
func run(ctx context.Context) (Output, error) {
    return Output{DBHost: "localhost", Port: 8080}, nil
}

Now the database can specify the config node as a dependency and the engine will make sure it is run after the config node is executed. Nodes access dependencies via graft.Dep[T]:

// nodes/db/db.go
package db

import (
    "context"
    "github.com/grindlemire/graft"
    "myapp/nodes/config"
)

const ID graft.ID = "db"

// Every node has an output type so other nodes can use it
type Output struct {
    Pool *sql.DB
}

func init() {
    graft.Register(graft.Node[Output]{
        ID:        ID,
        // This node depends on the config node
        DependsOn: []graft.ID{config.ID},
        Run:       run,
        // Nodes can choose to be cached so dependencies don't re-run the code every time
        Cacheable: true
    })
}

func run(ctx context.Context) (Output, error) {
    // We can get the config from the graph using the Dep function.
    cfg, err := graft.Dep[config.Output](ctx)
    if err != nil {
        return Output{}, err
    }
    pool, err := sql.Open("postgres", fmt.Sprintf("host=%s port=%d", cfg.DBHost, cfg.Port))
    if err != nil {
        return Output{}, err
    }
    return Output{Pool: pool}, nil
}

Execute the Graph

Import node packages for side-effect registration, then execute:

package main

import (
    "context"
    "log"

    "github.com/grindlemire/graft"
    _ "myapp/nodes/config"
    _ "myapp/nodes/db"
    _ "myapp/nodes/api"
)

func main() {
    // We could run the entire graph
    results, err := graft.Execute(context.Background())
    if err != nil {
        log.Fatal(err)
    }
    db := results["db"].(*sql.DB)
    // use db...


    // OR we could just run what we need to for a chosen dependency
    // db, _, err := graft.ExecuteFor[db.Output](context.Background())
}

Subgraph Execution

You can choose to only run a specific node and its transitive dependencies with type-safe results:

// Only executes "api" and whatever it depends on
// Returns typed result directly, plus full results map for accessing dependencies
api, results, err := graft.ExecuteFor[api.Output](ctx)
if err != nil {
    log.Fatal(err)
}
// api is already typed as api.Output
// the results map is available for accessing other node outputs if needed
config, err := graft.Result[config.Output](results)

Caching

Mark nodes as cacheable to avoid re-execution across calls:

graft.Register(graft.Node[Output]{
    ID:        ID,
    DependsOn: []graft.ID{},
    Run:       run,
    Cacheable: true, // output cached after first execution
})

By default, a global in-memory cache is used. Options for control:

// Use a custom cache
results, _ := graft.Execute(ctx, graft.WithCache(myCache))

// Force re-execution of specific nodes
results, _ := graft.Execute(ctx, graft.IgnoreCache("config"))

// Disable caching entirely
results, _ := graft.Execute(ctx, graft.DisableCache())

Dependency Validation

Static analysis catches dependency mismatches at test time:

func TestNodeDependencies(t *testing.T) {
    graft.AssertDepsValid(t, ".")
}

Catches:

  • Using Dep[T](ctx) without declaring the corresponding dependency in DependsOn
  • Declaring a dependency that's never used

Why use this library?

As teams or projects scale it can be challenging to efficiently route dependencies through the application while still writing idiomatic Go. Dependency injection frameworks attempt to solve this by allowing packages to just specify their dependencies but not how they are executed. However the larger dependency injection frameworks in Go either rely on reflection or large amounts of codegen.

This library attempts to be a lighter weight and more straightforward alternative without relying either on reflection or code generation. Simply specify nodes in their own package and register them in an init function, then the engine take care of the rest.

License

MIT

Documentation

Overview

Package graft provides a graph-based dependency execution framework.

Graft allows you to define nodes that declare their dependencies explicitly, and executes them in topological order with automatic parallelization. Nodes at the same level (no interdependencies) run concurrently.

Quick Start

Define nodes with typed Run functions:

graft.Register(graft.Node[Config]{
    ID:        "config",
    DependsOn: []graft.ID{},
    Run: func(ctx context.Context) (Config, error) {
        return Config{Host: "localhost"}, nil
    },
})

graft.Register(graft.Node[*sql.DB]{
    ID:        "db",
    DependsOn: []graft.ID{"config"},
    Run: func(ctx context.Context) (*sql.DB, error) {
        cfg, err := graft.Dep[Config](ctx)
        if err != nil {
            return nil, err
        }
        return connectDB(cfg), nil
    },
})

Execute the graph:

results, err := graft.Execute(ctx)
if err != nil {
    log.Fatal(err)
}
db := results["db"].(*sql.DB)

Type Safety

The generic Node[T] type ensures compile-time type checking on Run return values. The type parameter T specifies what the node produces.

Dependency Access

Use Dep to retrieve dependency outputs with type safety:

cfg, err := graft.Dep[config.Output](ctx)

Subgraph Execution

Use ExecuteFor to execute a specific node and its transitive dependencies:

appOut, results, err := graft.ExecuteFor[app.Output](ctx)
// appOut is typed; results map available for accessing other outputs

Static Analysis

Use AssertDepsValid in tests to verify dependency declarations:

func TestDeps(t *testing.T) {
    graft.AssertDepsValid(t, ".")
}

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func AssertDepsValid

func AssertDepsValid(t testing.TB, dir string)

AssertDepsValid is a test helper that validates all graft.Node dependency declarations in the specified directory match their actual usage.

Add this to your test suite to catch dependency mismatches at test time rather than runtime. It uses AST analysis to compare DependsOn declarations against actual Dep[T] calls in Run functions.

This will fail the test if:

  • Any node uses Dep[T](ctx, "x") without declaring "x" in DependsOn
  • Any node declares a dependency in DependsOn but never uses it

Basic usage in your test file:

func TestNodeDependencies(t *testing.T) {
    graft.AssertDepsValid(t, ".")
}

For a specific subdirectory:

func TestNodeDependencies(t *testing.T) {
    graft.AssertDepsValid(t, "./nodes")
}

Example failure output:

graft.AssertDepsValid: db (nodes/db/db.go): undeclared deps: [cache]
  → node "db" uses Dep[T](ctx, "cache") but does not declare it in DependsOn

func Dep

func Dep[T any](ctx context.Context) (T, error)

Dep retrieves a dependency's output from the context with type assertion.

This is the primary way for nodes to access their dependencies' outputs. The type parameter T specifies the expected output type, and the node ID is derived from T using the type-to-ID mapping established at registration.

Returns an error if:

  • The type T is not registered as a node output
  • The context has no results (called outside of a node's Run function)
  • The dependency is not found (not declared in DependsOn)
  • The dependency's output cannot be asserted to type T

Example:

func(ctx context.Context) (MyOutput, error) {
    cfg, err := graft.Dep[config.Output](ctx)
    if err != nil {
        return MyOutput{}, err
    }
    // use cfg...
}

func Execute added in v0.1.3

func Execute(ctx context.Context, opts ...Option) (map[ID]any, error)

Execute runs all registered nodes and returns their results.

Nodes are executed in topological order with automatic parallelization. Nodes at the same dependency level run concurrently.

By default, uses the global registry. Use WithRegistry for a custom registry.

Example:

import (
    _ "myapp/nodes/config"
    _ "myapp/nodes/db"
)

func main() {
    results, err := graft.Execute(ctx)
    if err != nil {
        log.Fatal(err)
    }
    db := results["db"].(*sql.DB)
}

func ExecuteFor added in v0.1.3

func ExecuteFor[T any](ctx context.Context, opts ...Option) (T, results, error)

ExecuteFor runs the node that produces type T and its transitive dependencies.

The target node is determined by the type parameter T, which must match the output type used when registering the node. Returns the typed result and the full results map for accessing other node outputs if needed.

By default, uses the global registry. Use WithRegistry for a custom registry.

Returns an error if the type is not registered or execution fails.

Example:

appOut, results, err := graft.ExecuteFor[app.Output](ctx)
// appOut is typed as app.Output
// results map available for accessing dependencies:
config, _ := graft.Result[config.Output](results)

func Register

func Register[T any](n Node[T])

Register adds a typed node to the global registry.

The type parameter is erased internally for heterogeneous storage. This is typically called from init() functions in node packages, ensuring all nodes are registered before main() runs. This pattern allows nodes to be self-registering via blank imports.

Panics if a node with the same ID is already registered. This catches accidental ID collisions at startup.

Example:

// nodes/config/config.go
package config

type Output struct {
    Host string
    Port int
}

func init() {
    graft.Register(graft.Node[Output]{
        ID:        "config",
        DependsOn: []graft.ID{},
        Run:       loadConfig,
    })
}

func loadConfig(ctx context.Context) (Output, error) {
    return Output{Host: "localhost", Port: 5432}, nil
}

Then import the package for its side effects:

import _ "myapp/nodes/config"

func Registry

func Registry() map[ID]node

Registry returns a copy of all registered nodes.

The returned map is a copy; modifications do not affect the global registry. This is commonly passed to WithRegistry for custom execution scenarios.

Example:

nodes := graft.Registry()
fmt.Printf("Registered %d nodes\n", len(nodes))

func ResetDefaultCache added in v0.1.3

func ResetDefaultCache()

ResetDefaultCache clears the global default cache. This is primarily useful for test isolation.

func ResetRegistry added in v0.1.3

func ResetRegistry()

ResetRegistry clears the global registry. This is primarily useful for test isolation.

func Result added in v0.1.3

func Result[T any](r results) (T, error)

Result retrieves a node's output from a results map with type assertion.

This is used after Execute/ExecuteFor to access node outputs from the returned results map. The node ID is derived from T using the type-to-ID mapping established at registration.

Returns an error if:

  • The type T is not registered as a node output
  • The node is not found in the results
  • The output cannot be asserted to type T

Example:

results, _ := graft.Execute(ctx)
cfg, err := graft.Result[config.Output](results)

func ValidateDeps

func ValidateDeps(dir string) error

ValidateDeps is a convenience function that returns an error if any dependency issues are found.

Pass "." for the current directory or a specific path. This is useful for CI integration or programmatic validation.

Example:

if err := graft.ValidateDeps("./nodes"); err != nil {
    log.Fatal(err)
}

Types

type AnalysisResult

type AnalysisResult struct {
	// NodeID is the ID field value from the analyzed node.
	NodeID string

	// File is the path to the source file containing the node.
	File string

	// DeclaredDeps are the dependency IDs listed in the DependsOn field.
	DeclaredDeps []string

	// UsedDeps are the dependency IDs accessed via Dep[T] calls in Run.
	UsedDeps []string

	// Undeclared are dependencies used but not declared in DependsOn.
	// These will cause runtime errors.
	Undeclared []string

	// Unused are dependencies declared but never used.
	// These indicate dead code or missing implementation.
	Unused []string
}

AnalysisResult contains the result of analyzing a node's dependency usage.

It captures both declared dependencies (in DependsOn) and used dependencies (via Dep[T] calls), allowing detection of mismatches.

func AnalyzeDir

func AnalyzeDir(dir string) ([]AnalysisResult, error)

AnalyzeDir analyzes all Go files in a directory for dependency correctness.

It recursively walks the directory, parsing each .go file (excluding _test.go) and extracting graft.Node[T] definitions. For each node found, it compares declared dependencies against actual Dep[T] usage.

Returns all nodes found with their analysis results. Use AnalysisResult.HasIssues to filter for problems.

Example:

results, err := graft.AnalyzeDir("./nodes")
if err != nil {
    log.Fatal(err)
}
for _, r := range results {
    if r.HasIssues() {
        fmt.Println(r.String())
    }
}

func AnalyzeFile

func AnalyzeFile(path string) ([]AnalysisResult, error)

AnalyzeFile analyzes a single Go file for graft.Node[T] dependency correctness.

Parses the file's AST and finds all graft.Node[T] composite literals. For each node, it extracts:

  • The ID field value
  • Dependencies from DependsOn
  • Dependencies used via Dep[T] calls in the Run function

Returns one AnalysisResult per node found in the file.

func CheckDepsValid

func CheckDepsValid(dir string) ([]AnalysisResult, error)

CheckDepsValid is like AssertDepsValid but returns results instead of failing.

This is useful for custom validation logic, reporting, or CI integration where you need programmatic access to the results.

Example:

results, err := graft.CheckDepsValid("./nodes")
if err != nil {
    log.Fatal(err)
}
for _, r := range results {
    if r.HasIssues() {
        notify(r.NodeID, r.Undeclared, r.Unused)
    }
}

func (AnalysisResult) HasIssues

func (r AnalysisResult) HasIssues() bool

HasIssues returns true if there are undeclared or unused dependencies.

func (AnalysisResult) String

func (r AnalysisResult) String() string

String returns a human-readable summary of issues.

Returns "NodeID: OK" if there are no issues, otherwise returns a summary of undeclared and unused dependencies.

type Cache added in v0.1.3

type Cache interface {
	// Snapshot returns a copy of the cache.
	Snapshot() map[ID]any

	// Get retrieves a cached value. Returns (value, true, nil) on hit,
	// (nil, false, nil) on miss, or (nil, false, err) on failure.
	Get(ctx context.Context, id ID) (any, bool, error)

	// Set stores a value in the cache.
	Set(ctx context.Context, id ID, value any) error
}

Cache defines the interface for node output caching. Implementations can be in-memory, Redis, disk, etc.

type ID added in v0.1.2

type ID string

type MemoryCache added in v0.1.3

type MemoryCache struct {
	// contains filtered or unexported fields
}

MemoryCache is a simple thread-safe in-memory cache.

func DefaultCache added in v0.1.3

func DefaultCache() *MemoryCache

DefaultCache returns the global cache instance. This can be used to inspect or clear cached values.

func NewMemoryCache added in v0.1.3

func NewMemoryCache() *MemoryCache

NewMemoryCache creates a new in-memory cache.

func (*MemoryCache) Clear added in v0.1.3

func (m *MemoryCache) Clear()

Clear removes all entries from the cache.

func (*MemoryCache) Delete added in v0.1.3

func (m *MemoryCache) Delete(ids ...ID)

Delete removes specific entries from the cache.

func (*MemoryCache) Get added in v0.1.3

func (m *MemoryCache) Get(_ context.Context, id ID) (any, bool, error)

Get retrieves a value from the cache.

func (*MemoryCache) Set added in v0.1.3

func (m *MemoryCache) Set(_ context.Context, id ID, value any) error

Set stores a value in the cache.

func (*MemoryCache) Snapshot added in v0.1.3

func (m *MemoryCache) Snapshot() map[ID]any

Snapshot returns a copy of all cached values (useful for debugging/inspection).

type Node

type Node[T any] struct {
	// ID is the unique identifier for this node.
	// This is used to reference the node in DependsOn lists and Dep calls.
	ID ID

	// DependsOn lists the IDs of nodes that must complete before this node runs.
	// The engine ensures all dependencies have completed and their outputs
	// are available via Dep before calling Run.
	DependsOn []ID

	// Run executes the node's business logic and returns a typed output.
	// Dependencies are accessed via Dep[T](ctx).
	Run func(ctx context.Context) (T, error)

	// Cacheable indicates whether this node's output should be cached.
	// When true and a cache is provided via WithCache, the node's output
	// is stored after first execution and reused on subsequent runs.
	// Default is false (not cached).
	Cacheable bool
}

Node represents a single node in the dependency graph with a typed output.

The type parameter T specifies the output type of the Run function, providing compile-time type safety. Each node has a unique ID, declares its dependencies, and provides a Run function that executes its business logic.

Example:

graft.Node[MyOutput]{
    ID:        "mynode",
    DependsOn: []graft.ID{config.ID, db.ID},
    Run: func(ctx context.Context) (MyOutput, error) {
        cfg, _ := graft.Dep[config.Output](ctx)
        db, _ := graft.Dep[db.Output](ctx)
        return doWork(cfg, db.Pool), nil
    },
}

type Option added in v0.1.3

type Option func(*config)

Option configures execution behavior.

func DisableCache added in v0.1.3

func DisableCache() Option

DisableCache disables the use of the default global cache.

Example:

// Disable the use of the default global cache
results, err := graft.Execute(ctx, graft.DisableCache())

func IgnoreCache added in v0.1.3

func IgnoreCache(ids ...ID) Option

IgnoreCache forces re-execution of the specified cacheable nodes, bypassing any cached values. The fresh results are still written back to the cache.

This is useful for invalidating specific nodes without clearing the entire cache (e.g., to refresh config after a change).

Example:

// Re-fetch config even though it's cacheable
results, err := graft.Execute(ctx,
    graft.WithCache(cache),
    graft.IgnoreCache("config"),
)

func MergeRegistry added in v0.1.3

func MergeRegistry(registry map[ID]node) Option

MergeRegistry merges the provided registry with the global registry. On conflicts, the provided registry takes precedence.

This is useful for testing where you want to override specific nodes while keeping the rest of the registered graph.

Example:

// Override just the "db" node for testing
results, err := graft.Execute(ctx, graft.MergeRegistry(mockNodes))

func WithCache added in v0.1.3

func WithCache(cache Cache) Option

WithCache overrides the default global cache with a custom cache.

By default, Execute/ExecuteFor use a global in-memory cache (similar to the global registry), so caching works automatically across calls. Use WithCache to provide a custom cache implementation (e.g., Redis) or an isolated cache for testing.

Example:

// Use a custom cache instead of the global default
customCache := graft.NewMemoryCache()
results, _ := graft.Execute(ctx, graft.WithCache(customCache))

func WithRegistry added in v0.1.3

func WithRegistry(registry map[ID]node) Option

WithRegistry uses a custom node registry instead of the global registry.

Example:

results, err := graft.Execute(ctx, graft.WithRegistry(customNodes))

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL