SuperAPI AI Directory

dependency-injection

General & other

View repository

Updated 10-15 22:40

About this skill

Dependency Injection in Godot 4.3+

Patterns for wiring dependencies between systems so nodes stay loosely coupled, swappable, and testable. All examples target Godot 4.3+ with no deprecated APIs.

Related skills: godot-testing for test-friendly architecture, event-bus for signal-based decoupling, godot-project-setup for autoload registration.


1. The Problem

Tight coupling makes code hard to test, extend, and swap. The most common form in Godot is reaching directly into a global autoload from everywhere in the codebase.

# BAD — tight coupling via direct autoload access scattered everywhere
player.gd

func take_damage(amount: int) -> void: health -= amount AudioManager.play_sfx("hurt") # hard dependency on AudioManager UIManager.update_health_bar(health) # hard dependency on UIManager if health <= 0: GameState.record_death() # hard dependency on GameState

enemy.gd

func attack() -> void: AudioManager.play_sfx("attack") # same AudioManager dependency again


```csharp
// BAD — tight coupling via direct autoload / global access scattered everywhere

// Player.cs
public partial class Player : CharacterBody3D
{
    private int _health = 100;

    public void TakeDamage(int amount)
    {
        _health -= amount;
        GetNode<AudioManager>("/root/AudioManager").PlaySfx("hurt");        // hard dependency
        GetNode<UIManager>("/root/UIManager").UpdateHealthBar(_health);     // hard dependency
        if (_health <= 0)
            GetNode<GameState>("/root/GameState").RecordDeath();            // hard dependency
    }
}

// Enemy.cs
public partial class Enemy : CharacterBody3D
{
    public void Attack()
    {
        GetNode<AudioManager>("/root/AudioManager").PlaySfx("attack");      // same dependency again
    }
}

Problems with this approach:

  • Every node that calls AudioManager directly is coupled to its concrete implementation.
  • Swapping AudioManager for a different implementation requires changing every caller.
  • Unit-testing Player in isolation is impossible — AudioManager, UIManager, and GameState must all exist and be valid.
  • Autoload initialization order bugs silently break behaviour when scenes load.
  • Hidden dependencies make it hard to see what a class actually needs to function.

2. Approach Comparison

Pattern Complexity Testability Best For
Autoloads Low Low Truly global singletons: audio, settings, platform services
@export Injection Low High Most nodes — wire deps in the editor, no runtime lookup needed
Service Locator Medium Medium Plugins, optional systems, swappable implementations at runtime
Scene Injection Low High Parent-to-child wiring: Level sets up Enemy, HUD sets up sub-panels

3. Autoloads as Singletons

Register a script in Project Settings → Globals → Autoload for global access (AudioManager.play_sfx(...), GameState.score = 100). Best for cross-cutting concerns: audio, save state, event bus, settings. Resist autoloading domain-specific systems (those should be scene-injected).

See references/autoloads.md for the full AudioManager example (SFX + crossfade music) in GDScript + C#.


4. @export Node Injection

Expose collaborator nodes as @export var health_component: HealthComponent, then wire in the Inspector or via parent scene. Lifecycle: @export properties are assigned BEFORE _ready().

See references/export-injection.md for full @export patterns (GDScript + C#) and lifecycle notes.


5. Service Locator Pattern

A central registry autoload mapping String keys to service instances. Services register themselves at _ready(), deregister at _exit_tree(), consumers call ServiceLocator.get(name). Useful when you want flexible runtime swap of implementations (testing, mods, A/B variants).

See references/service-locator.md for the full Service Locator (GDScript + C#) with typed helper methods.


6. Scene Injection

Parent scene loads its children, then in _ready() walks the tree assigning dependencies (enemy.player = $Player). Children declare @export properties but the parent — not the Inspector — sets them. Best for game-specific dependencies that change per level.

See references/scene-injection.md for the parent-injects-children pattern (GDScript + C#).


7. Testing with Dependency Injection

Injecting fakes / test doubles is what makes nodes testable. For autoloads: mock-replace before the test scene loads. For @export injection: swap the export to a test double. For Service Locator: register a fake under the same key.

See references/testing-with-di.md for GUT-based test patterns showing each injection technique.


8. When to Use What

Situation Recommended Pattern
Service used by nearly every node in every scene Autoload singleton
Node needs 1–3 deps, scene is editor-authored @export injection
System is optional or swappable at runtime Service Locator
Parent scene constructs children and knows their needs Scene injection
Writing tests for a node with external dependencies @export or property injection + stubs
Plugin that must work in any project Service Locator (self-registers, no assumptions)
Two sibling nodes need the same dep Let their parent hold it and inject downward

Quick decision guide:

Does every scene in the project need it?
  YES → Autoload singleton
  NO  ↓

Is the dependency known at edit-time and wired in the Inspector?
  YES → @export injection
  NO  ↓

Does the dependency need to be swapped at runtime (plugins, A/B testing)?
  YES → Service Locator
  NO  ↓

Does a parent scene own both the consumer and the dependency?
  YES → Scene injection
  NO  → Reconsider — either promote to autoload or restructure ownership

9. Anti-patterns

Autoload for everything

# BAD — GameManager, EnemySpawner, InventorySystem, DialogueSystem all as autoloads.
# Every node in the game is coupled to every other system at module level.
# Test one component → must initialise all autoloads.

# GOOD — Only AudioManager, Settings, and SceneTransition are autoloads.
# EnemySpawner is a node in the Level scene, injected into enemies that need it.
// BAD — GameManager, EnemySpawner, InventorySystem, DialogueSystem all as autoloads.
// Every node in the game is coupled to every other system at module level.
// Test one component → must initialise all autoloads.

// GOOD — Only AudioManager, Settings, and SceneTransition are autoloads.
// EnemySpawner is a node in the Level scene, injected into enemies that need it.

Deep dependency chains

# BAD — Player needs HealthComponent, which needs AudioManager,
# which needs SoundBank, which needs FileSystem...
# A change deep in the chain breaks everything above it.

# GOOD — flatten: HealthComponent takes only AudioManager (or a narrow interface).
# Each node declares only immediate dependencies.
// BAD — Player needs HealthComponent, which needs AudioManager,
// which needs SoundBank, which needs FileSystem...
// A change deep in the chain breaks everything above it.

// GOOD — flatten: HealthComponent takes only AudioManager (or a narrow interface).
// Each node declares only immediate dependencies.

Circular dependencies

# BAD
# PlayerController._ready() calls ServiceLocator.get_service("inventory")
# InventorySystem._ready() calls ServiceLocator.get_service("player")
# Neither can fully initialise because the other isn't ready yet.

# GOOD — break the cycle with a signal.
# InventorySystem emits item_used; PlayerController connects to it.
# PlayerController never holds a reference to InventorySystem at all.
// BAD
// PlayerController._Ready() calls ServiceLocator.GetService("inventory")
// InventorySystem._Ready() calls ServiceLocator.GetService("player")
// Neither can fully initialise because the other isn't ready yet.

// GOOD — break the cycle with a signal.
// InventorySystem emits ItemUsed; PlayerController connects to it.
// PlayerController never holds a reference to InventorySystem at all.

Service Locator as a god object

# BAD — everything is registered: enemies, UI panels, individual nodes.
# ServiceLocator becomes a second, untyped scene tree.

# GOOD — only register stable, long-lived services (audio, analytics, save system).
# Short-lived nodes are wired by their parent via scene injection.
// BAD — everything is registered: enemies, UI panels, individual nodes.
// ServiceLocator becomes a second, untyped scene tree.

// GOOD — only register stable, long-lived services (audio, analytics, save system).
// Short-lived nodes are wired by their parent via scene injection.

Forgetting null checks after injection

# BAD — crashes if the @export was never set in the editor
func take_damage(amount: int) -> void:
    audio.play_sfx("hurt")   # NullReferenceError if audio was not wired

# GOOD — guard or assert clearly
func take_damage(amount: int) -> void:
    assert(audio != null, "HealthComponent: audio dependency was not injected")
    audio.play_sfx("hurt")

# OR — treat it as optional
func take_damage(amount: int) -> void:
    if audio != null:
        audio.play_sfx("hurt")
// BAD — crashes if the [Export] was never set in the editor
public void TakeDamage(int amount)
{
    _audio.PlaySfx("hurt");   // NullReferenceException if _audio was not wired
}

// GOOD — guard or assert clearly
public void TakeDamage(int amount)
{
    if (_audio == null)
    {
        GD.PushError("HealthComponent: audio dependency was not injected");
        return;
    }
    _audio.PlaySfx("hurt");
}

// OR — treat it as optional
public void TakeDamage(int amount)
{
    _audio?.PlaySfx("hurt");
}

10. Checklist

  • Autoloads are used only for genuinely global services (audio, settings, platform)
  • Nodes declare their dependencies explicitly (@export or a public property) rather than calling get_node on distant relatives
  • @export fields are validated (assert or null check) before use
  • Service Locator services call unregister in _exit_tree() / _ExitTree()
  • Scene injection is done in the parent's _ready(), after children are fully initialised
  • No circular dependencies between services or autoloads
  • Each node depends only on its immediate collaborators — no deep chains
  • Test stubs/mocks are plain nodes that implement the same interface as the real service
  • C# @export ([Export]) dependencies are disconnected / cleared in _ExitTree() if they hold event subscriptions
  • Service Locator is not used to store scene-specific or short-lived nodes