Pragmatik Dev · Fast DI · Unified Tutorial

N-Tier Architecture in Unity

Keep MonoBehaviours dumb and testable. Business logic lives in POCOs that can be unit-tested outside Unity — just like web services and repositories. Learn to wire it all together with Fast DI's injection scopes.

Architecture Overview

This tutorial follows the same N-tier pattern you'd use in a web app: Controller → Service → Repository → Data Access. MonoBehaviours are the controllers — thin, dumb, and easy to test. Everything else is POCO code that can be tested with any unit testing framework.

  • Tutorial 1: Inject a service into a controller (Hierarchy scope)
  • Tutorial 2: Service depends on a repository (chain building)
  • Tutorial 3: Share a repository across services (Singleton scope)
  • Tutorial 4: FastDI auto-discovers and wires the full chain
  • Tutorial 5: Stateless utilities per-injection (Transient scope)

Generate Tutorial Scene

Use the Unity Editor menu to automatically generate a complete scene with all tutorial components pre-configured.

Tools → Fast DI → Generate Tutorial Scene
            

Creates 5 GameController + PlayerService pairs demonstrating each scope pattern.

1 · Basic injection (Hierarchy)

A controller injects a service. The service is a POCO with an interface — no Unity dependencies, fully testable. Scopes.Hierarchy finds the service in the scene tree (parents and children).

The controller does nothing but delegate. No business logic lives in the MonoBehaviour.

  1. Create a GameController GameObject and add the PlayerController script.
  2. Create a PlayerService GameObject and add the PlayerService script.
  3. Hit Play — console prints: Loading player: player1
using PragmatikDev.FastDI;
using UnityEngine;

// Controller — dumb, just delegates
public class GameController : InjectableBehaviour
{
    [Inject(Scopes.Hierarchy)]
    IPlayerService _playerService = null!;

    void Start()
    {
        _playerService.LoadPlayer("player1");
    }
}

// Service — pure POCO, testable outside Unity
public interface IPlayerService
{
    void LoadPlayer(string playerName);
}

public class PlayerService : IPlayerService
{
    public void LoadPlayer(string playerName)
    {
        Debug.Log("Loading player: " + playerName);
    }
}

2 · Service → Repository chain

Now the service depends on a repository. The service layer calls repository methods; the repository handles data access. FastDI auto-discovers the chain: Controller → Service → Repository → Database.

  1. Create GameController, PlayerService, PlayerRepository, and Database GameObjects.
  2. Add the scripts below to each.
  3. Hit Play — console prints the full chain:
Loading player: player1
Saving player data...
INSERT INTO players (name=player1, level=1)
Updating score to: 500
Saving score...
UPDATE players SET score=500

How it works: GameController injects IPlayerService. PlayerService injects IPlayerRepository. PlayerRepository injects IDatabase. FastDI auto-discovers all four and wires them up in the right order — no manual registration needed.

using PragmatikDev.FastDI;
using UnityEngine;

// Controller — dumb, just delegates
public class GameController : InjectableBehaviour
{
    [Inject(Scopes.Hierarchy)]
    IPlayerService _playerService = null!;

    void Start()
    {
        _playerService.LoadPlayer("player1");
        _playerService.UpdateScore(500);
    }
}

// Service layer — business logic
public interface IPlayerService
{
    void LoadPlayer(string playerName);
    void UpdateScore(int score);
}

public class PlayerService : IPlayerService
{
    [Inject(Scopes.Singleton)]
    IPlayerRepository _repo = null!;

    public void LoadPlayer(string playerName)
    {
        Debug.Log("Loading player: " + playerName);
        _repo.SavePlayer(playerName, 0);
    }

    public void UpdateScore(int score)
    {
        Debug.Log("Updating score to: " + score);
        _repo.SaveScore(score);
    }
}

// Repository layer — data operations
public interface IPlayerRepository
{
    void SavePlayer(string name, int level);
    void SaveScore(int score);
}

public class PlayerRepository : IPlayerRepository
{
    [Inject(Scopes.Singleton)]
    IDatabase _db = null!;

    public void SavePlayer(string name, int level)
    {
        Debug.Log("Saving player data...");
        _db.Insert("players", $"name={name}, level={level}");
    }

    public void SaveScore(int score)
    {
        Debug.Log("Saving score...");
        _db.Update("players", $"score={score}");
    }
}

// Data access layer — pure POCO
public interface IDatabase
{
    void Insert(string table, string data);
    void Update(string table, string data);
}

public class Database : IDatabase
{
    public void Insert(string table, string data)
    {
        Debug.Log($"INSERT INTO {table} ({data})");
    }

    public void Update(string table, string data)
    {
        Debug.Log($"UPDATE {table} SET {data}");
    }
}

3 · Sharing repositories (Singleton)

Multiple services need the same repository. Use Scopes.Singleton so they share one instance. This is the same pattern as sharing a database connection or caching layer in a web app.

IPlayerRepository is injected into both IPlayerService and IScoreService. They both write to the same IDatabase instance.

  1. Create GameController, PlayerService, ScoreService, PlayerRepository, and Database GameObjects.
  2. Hit Play — both services use the same repository and database.
using PragmatikDev.FastDI;
using UnityEngine;

// Controller — dumb, just delegates
public class GameController : InjectableBehaviour
{
    [Inject(Scopes.Singleton)]
    IPlayerService _playerService = null!;

    void Start()
    {
        _playerService.LoadPlayer("player1");
    }
}

// Service — business logic
public interface IPlayerService
{
    void LoadPlayer(string playerName);
}

public class PlayerService : IPlayerService
{
    [Inject(Scopes.Singleton)]
    IPlayerRepository _repo = null!;

    public void LoadPlayer(string playerName)
    {
        Debug.Log("Loading player: " + playerName);
        _repo.SavePlayer(playerName, 1);
    }
}

// ScoreService — also uses the same repository
public interface IScoreService
{
    void AddScore(int points);
}

public class ScoreService : IScoreService
{
    [Inject(Scopes.Singleton)]
    IPlayerRepository _repo = null!;

    public void AddScore(int points)
    {
        Debug.Log("Adding " + points + " points");
        _repo.SaveScore(points);
    }
}

// Repository — shared by multiple services
public interface IPlayerRepository
{
    void SavePlayer(string name, int level);
    void SaveScore(int score);
}

public class PlayerRepository : IPlayerRepository
{
    [Inject(Scopes.Singleton)]
    IDatabase _db = null!;

    public void SavePlayer(string name, int level)
    {
        Debug.Log("Saving player data...");
        _db.Insert("players", $"name={name}, level={level}");
    }

    public void SaveScore(int score)
    {
        Debug.Log("Saving score...");
        _db.Update("players", $"score={score}");
    }
}

// Data access — pure POCO
public interface IDatabase
{
    void Insert(string table, string data);
    void Update(string table, string data);
}

public class Database : IDatabase
{
    public void Insert(string table, string data)
    {
        Debug.Log($"INSERT INTO {table} ({data})");
    }

    public void Update(string table, string data)
    {
        Debug.Log($"UPDATE {table} SET {data}");
    }
}

4 · Auto-discovery of chains

FastDI auto-discovers the full dependency chain. You don't need to register anything. When PlayerController runs, it needs IPlayerService. FastDI finds PlayerService, sees it needs IPlayerRepository, finds it, sees it needs IDatabase, finds it — and wires everything up automatically.

This is the same pattern as web frameworks that auto-wire dependency trees. The only requirement is that each POCO implements an interface and is present in the scene.

  1. Create the GameObjects: GameController, PlayerService, PlayerRepository, Database.
  2. Add the scripts. No registration needed.
  3. Hit Play — the full chain executes automatically.
using PragmatikDev.FastDI;
using UnityEngine;

// Controller — dumb, just delegates
public class GameController : InjectableBehaviour
{
    [Inject(Scopes.Singleton)]
    IPlayerService _playerService = null!;

    void Start()
    {
        _playerService.LoadPlayer("player1");
    }
}

// Service layer
public interface IPlayerService
{
    void LoadPlayer(string playerName);
}

public class PlayerService : IPlayerService
{
    [Inject(Scopes.Singleton)]
    IPlayerRepository _repo = null!;

    public void LoadPlayer(string playerName)
    {
        Debug.Log("Loading player: " + playerName);
        _repo.SavePlayer(playerName, 1);
    }
}

// Repository layer
public interface IPlayerRepository
{
    void SavePlayer(string name, int level);
    void SaveScore(int score);
}

public class PlayerRepository : IPlayerRepository
{
    [Inject(Scopes.Singleton)]
    IDatabase _db = null!;

    public void SavePlayer(string name, int level)
    {
        Debug.Log("Saving player data...");
        _db.Insert("players", $"name={name}, level={level}");
    }

    public void SaveScore(int score)
    {
        Debug.Log("Saving score...");
        _db.Update("players", $"score={score}");
    }
}

// Data access layer
public interface IDatabase
{
    void Insert(string table, string data);
    void Update(string table, string data);
}

public class Database : IDatabase
{
    public void Insert(string table, string data)
    {
        Debug.Log($"INSERT INTO {table} ({data})");
    }

    public void Update(string table, string data)
    {
        Debug.Log($"UPDATE {table} SET {data}");
    }
}

Key insight: You never register anything. FastDI scans the scene, finds interfaces and their implementations, and builds the chain. The only rule: each POCO must implement an interface, and the interface must be injectable.

5 · Stateless utilities (Transient)

Some utilities don't need to be cached — each injection gets a fresh instance. Use Scopes.Transient for stateless validators, parsers, and formatters.

IInputValidator is a pure POCO with no dependencies. Each time PlayerService injects it, a new instance is created. Same pattern as transient services in ASP.NET Core.

  1. Create GameController, PlayerService, PlayerRepository, Database, and InputValidator GameObjects.
  2. Hit Play — each validator is a different instance (different ID in output).
using PragmatikDev.FastDI;
using UnityEngine;

// Controller — dumb, just delegates
public class GameController : InjectableBehaviour
{
    [Inject(Scopes.Singleton)]
    IPlayerService _playerService = null!;

    void Start()
    {
        _playerService.LoadPlayer("player1");
        _playerService.ValidateInput("valid data");
    }
}

// Service layer
public interface IPlayerService
{
    void LoadPlayer(string playerName);
    void ValidateInput(string input);
}

public class PlayerService : IPlayerService
{
    [Inject(Scopes.Singleton)]
    IPlayerRepository _repo = null!;

    // Each field gets a fresh instance
    [Inject(Scopes.Transient)]
    IInputValidator _inputValidator = null!;

    public void LoadPlayer(string playerName)
    {
        Debug.Log("Loading player: " + playerName);
        _repo.SavePlayer(playerName, 1);
    }

    public void ValidateInput(string input)
    {
        bool valid = _inputValidator.Validate(input);
        Debug.Log("Input valid: " + valid);
    }
}

// Repository layer
public interface IPlayerRepository
{
    void SavePlayer(string name, int level);
    void SaveScore(int score);
}

public class PlayerRepository : IPlayerRepository
{
    [Inject(Scopes.Singleton)]
    IDatabase _db = null!;

    public void SavePlayer(string name, int level)
    {
        Debug.Log("Saving player data...");
        _db.Insert("players", $"name={name}, level={level}");
    }

    public void SaveScore(int score)
    {
        Debug.Log("Saving score...");
        _db.Update("players", $"score={score}");
    }
}

// Data access layer
public interface IDatabase
{
    void Insert(string table, string data);
    void Update(string table, string data);
}

public class Database : IDatabase
{
    public void Insert(string table, string data)
    {
        Debug.Log($"INSERT INTO {table} ({data})");
    }

    public void Update(string table, string data)
    {
        Debug.Log($"UPDATE {table} SET {data}");
    }
}

// Stateless utility — fresh instance each injection
public interface IInputValidator
{
    bool Validate(string input);
}

public class InputValidator : IInputValidator
{
    private int _instanceId = System.Guid.NewGuid().GetHashCode();

    public bool Validate(string input)
    {
        Debug.Log($"Validator #{_instanceId}: validating '{input}'");
        return !string.IsNullOrEmpty(input);
    }
}

When to use transient: Validators, parsers, formatters, or anything stateless where you want a fresh instance per injection. Same as ServiceCollection.AddTransient<T> in ASP.NET Core.

Summary

The pattern is simple: controllers are dumb, services are pure POCOs, repositories handle data, utilities are transient. MonoBehaviours have almost no code — they just inject a service and delegate. All business logic is testable outside Unity with any unit testing framework.

For per-scene services and advanced patterns, see the full product documentation.

← Documentation hub