Dependency Injection

In shortHow dependency injection decouples your code from the framework and manages service lifecycles in nodes and tools.

  • 4 min read
  • 8 sections
  • Updated
  • v0.10.0
  • Markdown

Dependency injection (DI) lets your graph nodes and tools declare what they need without importing framework internals. Instead of tightly coupling your code to global state or creating services inside functions, DI wires services in at call time. This makes your code easier to test, reuse, and maintain.

The problem: tight coupling and untestable code

Without DI, your functions must know how to create or access their dependencies:

Python
# Anti-pattern: tight coupling to framework and globals
def search_products(query: str) -> str:
    # Hardcoded import of framework internals
    db = get_database()  # global function
    config = get_config()  # another global
    
    results = db.query(f"SELECT * FROM products WHERE name LIKE '%{query}%'")
    return f"Found {len(results)} products"

This approach has several problems:

  • The function is hard to test; you must mock the global functions.
  • The function creates hidden dependencies that are not visible in its signature.
  • Reusing the function in another graph requires the same globals to exist.
  • Changing the database requires editing the function, not just the graph setup.

How dependency injection solves this

With DI, your function declares what it needs as parameters, and the framework provides those services at runtime:

Python
# Better: explicit dependencies via DI
def search_products(
    query: str,
    db: DatabaseClient = Inject[DatabaseClient],  # injected
    config: dict = Inject[dict],  # injected
) -> str:
    results = db.query(f"SELECT * FROM products WHERE name LIKE '%{query}%'")
    return f"Found {len(results)} products"

# In tests, you inject a mock:
mock_db = MockDatabaseClient()
# The function works with the mock; no globals to patch.

Now the dependencies are:

  • Explicit in the function signature.
  • Easy to swap out for tests.
  • Decoupled from the framework.

Two kinds of injection in 10xGraph

10xGraph provides two mechanisms for dependency injection, both built on InjectQ:

Automatic framework parameters

Some parameters are always available to nodes and tools without registration. These are managed by the framework and represent the current execution context:

  • state: The current AgentState: the full message history and custom fields.
  • config: Thread configuration, thread_id, user_id, run_id, recursion limit.
  • tool_call_id (tools only): The ID of the current tool call from the LLM.
  • emit (tools only): A function to emit structured progress events during long-running tools.
  • generated_id: A fresh ID generated by the framework’s ID generator.

These parameters are available to any node function or tool; they require no setup:

Python
def my_tool(
    query: str,                    # from the LLM's tool call
    state: AgentState,             # injected, no setup needed
    config: dict,                  # injected, no setup needed
    tool_call_id: str,             # injected, no setup needed
) -> str:
    user_id = config.get("user_id")
    history_length = len(state.context)
    return f"Query: {query} (user: {user_id}, history: {history_length})"

The LLM only sees query in the tool schema. The other three are invisible.

Custom service injection

For application-level services (database clients, API clients, callback managers, custom storage backends), you register them with the DI container and declare them with Inject[Type] defaults:

Python
from injectq import Inject, InjectQ

class DatabaseClient:
    def query(self, sql: str) -> list: ...

# Register in the container
container = InjectQ.get_instance()
container.bind_instance(DatabaseClient, DatabaseClient(dsn="..."))

# Inject into a tool
def search_products(
    query: str,
    db: DatabaseClient = Inject[DatabaseClient],
) -> str:
    results = db.query(f"SELECT * FROM products WHERE name LIKE '%{query}%'")
    return f"Found {len(results)} products"

The framework resolves Inject[DatabaseClient] by looking up DatabaseClient in the container and passes the instance as db.

Container lifecycle and scoping

The DI container can be global (shared across all graphs) or scoped to a single graph. This controls where services live:

Global container (default):

Python
container = InjectQ.get_instance()  # singleton
container.bind_instance(DatabaseClient, db)

graph1 = StateGraph()  # uses the global container
graph2 = StateGraph()  # also uses the global container
# Both graphs share the same db instance

Scoped container (isolated per graph, useful for tests):

Python
# Each test gets a fresh container with test doubles
container = InjectQ()  # new isolated container
container.bind_instance(DatabaseClient, MockDatabaseClient())

graph = StateGraph(container=container)  # uses this container, not the global one

Passing container= to StateGraph activates the container and makes it the default for that graph’s nodes and tools.

When to use dependency injection

Use DI for services that:

  • Are expensive to create (database connections, API clients, model instances).
  • You want to swap out for tests (replace a real DB with a mock).
  • Are shared across multiple nodes or tools.
  • Have configuration that belongs at the graph level, not inside functions.

Examples: database clients, API credentials, cache backends, callback managers, custom memory stores, analytics clients.

Do not use DI for pure data (function arguments). Those go through the tool schema and the LLM’s tool calls.

Configuring the container for the API server

If you deploy your graph with the 10xgraph CLI server, register your container in 10xgraph.json so the server can pick it up automatically:

JSON
{
  "agent": "myapp.graph:app",
  "injectq": "myapp.services:container"
}

The server imports container from the myapp.services module, which must be an InjectQ instance, and uses it as its container. Your graph module should build its StateGraph with the same container (StateGraph(container=container)) so nodes and tools resolve the same bindings.

For the canonical injectable parameters and step-by-step examples

The complete list of built-in services registered by the framework (checkpointer, store, publisher, callback manager, ID generator, etc.) and detailed how-to examples live in How to use dependency injection. That page includes:

  • All automatically-available bindings.
  • Step-by-step registration patterns.
  • Complete runnable examples.
  • Testing patterns with isolated containers.
Last updated for v0.10.0Edit this page on GitHubReport an issue