npx skills add ...
npx skills add jame581/godotprompter --skill dependency-injection
Use when managing dependencies between systems — autoloads, service locators, @export injection, and scene injection patterns
npx skills add jame581/godotprompter --skill dependency-injection
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.
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.
Problems with this approach:
AudioManager directly is coupled to its concrete implementation.AudioManager for a different implementation requires changing every caller.Player in isolation is impossible — AudioManager, UIManager, and GameState must all exist and be valid.| 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 |
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#.
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
@exportpatterns (GDScript + C#) and lifecycle notes.
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.
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#).
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.
| 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:
@export or a public property) rather than calling get_node on distant relatives@export fields are validated (assert or null check) before useunregister in _exit_tree() / _ExitTree()_ready(), after children are fully initialised@export ([Export]) dependencies are disconnected / cleared in _ExitTree() if they hold event subscriptions// 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
}
}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# 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.# 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.# 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.# 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.# 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");
}