graft

package module
v0.2.3 Latest Latest
Warning

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

Go to latest
Published: Jan 6, 2026 License: MIT Imports: 8 Imported by: 2

README

graft

Go Reference CI Coverage Status Go Report Card Examples

Lightweight, type-safe dependency injection for Go.

Example Graph:
                                    ┌───────┐
                              ┌────▶│ cache │────┐
                              │     └───────┘    │
┌────────┐    ┌─────────┐     │                  │     ┌────────┐
│ config │───▶│ secrets │─────┤                  ├────▶│ server │
└────────┘    └─────────┘     │                  │     └────────┘
                              │     ┌────┐       │
                              └────▶│ db │───────┘
                                    └────┘

You define independent nodes for your dependency graph. Graft assembles and runs your graph optimally, and gives you type-safe access to dependencies. No reflection or codegen and a minimal but flexible API.

Why Graft?

  • Simple - No complex arg routing even in large projects.
  • No Codegen - No extra build step or opaque code.
  • Type Safe - Compile-time type safety for inputs and outputs and no reflection.
  • Concurrent - Independent nodes execute in parallel automatically.
  • Validatable - Validate your entire graph in CI and at compile time

Install

go get github.com/grindlemire/graft

Full API documentation: pkg.go.dev/github.com/grindlemire/graft

Usage

1. Define Nodes

Create a package for your node and register it in init().

// nodes/db/db.go
package db

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

// the ID for this node
const ID graft.ID = "db"

// what type you want to output from this node for others to consume
type Output struct { Pool *sql.DB }

func init() {
    // register our node with graft for our output type
    graft.Register(graft.Node[Output]{
        ID:        ID,
        // list any dependencies here (any cycles won't let you import it)
        DependsOn: []graft.ID{config.ID},
        Run:       run,
    })
}

// run gets executed by graft to produce our output from our dependencies
func run(ctx context.Context) (Output, error) {
    // Type-safe dependency injection
    cfg, err := graft.Dep[config.Output](ctx)
    if err != nil {
        return Output{}, err
    }

    return Output{Pool: connect(cfg)}, nil
}

2. Execute

Import your nodes for side-effects and run the engine.

package main

import (
    "github.com/grindlemire/graft"
    // import our nodes to register them
    _ "myapp/nodes/config"
    _ "myapp/nodes/db"
)

func main() {
    // Run a specific subgraph
    // Returns the typed output of the target node
    db, results, err := graft.ExecuteFor[db.Output](ctx)
    if err != nil {
         // handle error
    }

    // OR run the entire graph
    // results, err := graft.Execute(ctx)
}

Caching

Skip expensive calculations by marking nodes as cacheable.

graft.Register(graft.Node[Output]{
    // ...
    Cacheable: true,
})

Control cache behavior at runtime:

graft.Execute(ctx, graft.WithCache(customCache))
graft.Execute(ctx, graft.IgnoreCache(config.ID))

Validation

Catch missing or unused dependencies during tests.

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

Options for more detail:

// Verbose output - shows each node's declared and used dependencies
graft.AssertDepsValid(t, ".", graft.WithVerboseTesting())

// Debug output - shows AST-level tracing for troubleshooting
graft.AssertDepsValid(t, ".", graft.WithDebugTesting())

For programmatic access (CI integration, custom reporting):

results, err := graft.CheckDepsValid("./nodes")
for _, r := range results {
    if r.HasIssues() {
        // r.Undeclared - deps used but not declared
        // r.Unused - deps declared but not used
    }
}
Compile Time Graph Checking

Place each node in its own package and Go's import rules enforce a valid graph for you:

  • No cycles - To depend on node B, node A must import B's package. Circular imports don't compile.
  • No missing deps - Referencing config.ID in your DependsOn requires importing the config package. If it doesn't exist, the build fails.

See the examples directory for this pattern.

Visualization

Generate diagrams directly from your code for better visibility in large code bases

// Print ASCII graph to stdout
graft.PrintGraph(os.Stdout)

// Generate Mermaid syntax
graft.PrintMermaid(os.Stdout)

Why not Wire or Fx?

Wire

Wire uses code generation to wire dependencies at compile time. It's powerful but adds complexity:

  • No longer maintained (archived as of 2025-08-25)
  • Requires running wire before each build
  • Provider sets and injector functions add conceptual overhead

Graft uses plain Go init() functions and generics. No extra build step for CI, no generated files to manage, and nothing new to learn.

Fx

Fx wires dependencies at runtime using reflection. It's actively maintained and heavily used at Uber, but has trade-offs:

  • Errors surface at runtime, not compile time
  • Reflection can obscure stack traces, making debugging harder
  • The lifecycle model (fx.Invoke, fx.Lifecycle) adds a ton of boiler plate for most use cases

Graft resolves the graph at execution time but uses generics for type safety. If your types don't match, or you have a dependency cycle then the compiler tells you.

When to use Graft

Graft is a good fit when you want dependency injection without the tooling overhead of Wire or the runtime reflection of Fx. It is also a much smaller and less complex library compared to either Wire or Fx with many less opinions on how you structure your code.

Graft is intentionally minimal: define your nodes, declare your dependencies, and run.

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 for a specific node (returns typed output):

db, _, err := graft.ExecuteFor[*sql.DB](ctx)
if err != nil {
    log.Fatal(err)
}
// db is already typed as *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

View Source
var AnalyzeDirDebug = false

AnalyzeDirDebug controls whether AnalyzeDir prints debug information. Set this to true before calling AssertDepsValidVerbose to see file-level tracing.

Functions

func AnalyzeDir

func AnalyzeDir(dir string) ([]typeaware.Result, error)

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

This function uses type-aware analysis with go/packages and go/ssa to accurately detect dependency issues. It discovers all graft.Node[T] registrations in the directory and compares declared dependencies (in DependsOn) against actual Dep[T] usage in Run functions.

The type-aware approach is more robust than AST-based pattern matching:

  • Handles type aliases correctly
  • Resolves package imports accurately
  • Works with various code structures (dependencies in same package, etc.)
  • Uses SSA for precise dataflow analysis

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 AssertDepsValid

func AssertDepsValid(t testing.TB, dir string, opts ...AssertOption)

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) without declaring the corresponding dependency 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, ".")
}

With verbose output (shows each node's deps):

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

With AST debug output:

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

Example failure output:

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

func CheckDepsValid

func CheckDepsValid(dir string) ([]typeaware.Result, 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 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. Note that Execute is not type safe since it is executing the entire graph and exposing all the results. Use ExecuteFor instead to have a type safe output.

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 PrintGraph added in v0.1.6

func PrintGraph(w io.Writer, opts ...Option) error

PrintGraph outputs an ASCII representation of the dependency graph to the provided io.Writer.

func PrintMermaid added in v0.1.6

func PrintMermaid(w io.Writer, opts ...Option) error

PrintMermaid outputs a Mermaid diagram of the dependency graph to the provided io.Writer.

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

	// Cycles are circular dependency paths this node participates in.
	// Each cycle is represented as a path of node IDs forming a loop.
	// For example: ["svc5", "svc5-2", "svc5"] indicates svc5 → svc5-2 → svc5.
	Cycles [][]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 (AnalysisResult) HasIssues

func (r AnalysisResult) HasIssues() bool

HasIssues returns true if there are undeclared, unused dependencies, or cycles.

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, unused dependencies, and cycles.

type AssertOption added in v0.2.0

type AssertOption func(*AssertOpts)

AssertOption is a functional option for configuring AssertDepsValid.

func WithDebugTesting added in v0.2.0

func WithDebugTesting() AssertOption

WithDebugTesting enables AST-level debug output showing file walking, composite literal detection, and dependency extraction details.

func WithVerboseTesting added in v0.2.0

func WithVerboseTesting() AssertOption

WithVerboseTesting enables verbose output showing each node's declared and used dependencies along with their validation status.

type AssertOpts added in v0.2.0

type AssertOpts struct {
	Verbose bool // prints node summaries (DeclaredDeps, UsedDeps, Status)
	Debug   bool // prints AST-level tracing (file walking, composite literals, etc.)
}

AssertOpts configures the behavior of AssertDepsValid.

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
out, _, err := graft.ExecuteFor[app.Output](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
out, _, err := graft.ExecuteFor[app.Output](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
out, _, err := graft.ExecuteFor[app.Output](ctx, graft.MergeRegistry(mockNodes))

func Patch added in v0.1.5

func Patch[T any](n Node[T]) Option

Patch replaces a node with a custom node for testing.

The node is identified by the type T, which must match a registered node's output type. The patched node inherits DependsOn, Run, and Cacheable from the provided Node[T].

This is a no-op if type T is not registered.

Example:

// Replace db node with a mock that uses config
out, _, err := graft.ExecuteFor[app.Output](ctx,
    graft.Patch[db.Output](graft.Node[db.Output]{
        DependsOn: []graft.ID{"config"},
        Run: func(ctx context.Context) (db.Output, error) {
            cfg, _ := graft.Dep[config.Output](ctx)
            return db.Output{Pool: mockPool(cfg)}, nil
        },
    }),
)

func PatchValue added in v0.1.5

func PatchValue[T any](value T) Option

PatchValue replaces a node's output with a fixed value for testing.

The node is identified by the type T, which must match a registered node's output type. The patched node has no dependencies and simply returns the provided value.

This is a no-op if type T is not registered.

Example:

// Replace config node with a test value
results, err := graft.Execute(ctx,
    graft.PatchValue[config.Output](config.Output{Host: "test", Port: 9999}),
)

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()
out, _, err := graft.ExecuteFor[app.Output](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:

out, _, err := graft.ExecuteFor[app.Output](ctx, graft.WithRegistry(customNodes))

Jump to

Keyboard shortcuts

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