Unity Multi-Module Clean Architecture: A Simple Guide with Practical Examples

Summary (TL;DR): Learn how to implement Clean Architecture in Unity using a multi-module approach with practical examples. We'll build a simple inventory system that demonstrates separation of concerns, dependency inversion, and testable code structure that scales with your project.


Cover Image

[ADD IMAGE - Cover]
Suggestion: Unity editor screenshot showing organized project folders with clean architecture layers (Data, Domain, Presentation) alongside a simple UML diagram
Alt text: Unity project structure showing clean architecture folders and dependency flow diagram


Table of Contents

  1. What is Clean Architecture in Unity?
  2. Setting Up the Multi-Module Structure
  3. Domain Layer: Core Business Logic
  4. Data Layer: External Dependencies
  5. Presentation Layer: Unity-Specific Code
  6. Dependency Injection Setup
  7. Practical Example: Inventory System
  8. Testing Your Architecture
  9. Performance Considerations

1) What is Clean Architecture in Unity?

Clean Architecture in Unity separates your game logic from Unity-specific code, making your codebase more maintainable, testable, and scalable. Instead of having MonoBehaviours handle everything from UI to business logic, we create distinct layers with clear responsibilities.

The key benefits include:

  • Framework Independence: Core logic doesn't depend on Unity
  • Testability: Business logic can be unit tested without Unity
  • Maintainability: Changes in one layer don't affect others
  • Scalability: Easy to add new features and team members

Prerequisites: Unity 2022.3 LTS or newer, C# intermediate knowledge, basic understanding of interfaces and dependency injection

[ADD IMAGE - Architecture Diagram]
Alt: Clean architecture layers showing Domain, Data, and Presentation with dependency arrows


2) Setting Up the Multi-Module Structure

First, let's create our project structure. In Unity, we'll organize our code into Assembly Definitions (asmdef files) to enforce layer separation.

Project Folder Structure


Assets/
├── Scripts/
│   ├── Domain/              // Core business logic
│   │   ├── Domain.asmdef
│   │   ├── Entities/
│   │   ├── UseCases/
│   │   └── Interfaces/
│   ├── Data/                // External data sources
│   │   ├── Data.asmdef
│   │   ├── Repositories/
│   │   └── DataSources/
│   ├── Presentation/        // Unity-specific code
│   │   ├── Presentation.asmdef
│   │   ├── Views/
│   │   ├── Presenters/
│   │   └── MonoBehaviours/
│   └── Infrastructure/      // DI Container, etc.
│       └── Infrastructure.asmdef

Assembly Definition Setup

Create assembly definitions to prevent unwanted dependencies:

  • Domain.asmdef: No references (pure C#)
  • Data.asmdef: References only Domain
  • Presentation.asmdef: References Domain and Infrastructure
  • Infrastructure.asmdef: References Domain and Data

Tip: Use "Auto Referenced" = false on all asmdef files to have explicit control over dependencies


3) Domain Layer: Core Business Logic

The Domain layer contains your game's core business logic, entities, and use cases. It has zero dependencies on Unity or external frameworks.

Entities


// Domain/Entities/Item.cs
namespace Domain.Entities
{
    public class Item
    {
        public string Id { get; }
        public string Name { get; }
        public string Description { get; }
        public int MaxStackSize { get; }
        
        public Item(string id, string name, string description, int maxStackSize = 1)
        {
            Id = id;
            Name = name;
            Description = description;
            MaxStackSize = maxStackSize;
        }
    }
}

// Domain/Entities/InventorySlot.cs
namespace Domain.Entities
{
    public class InventorySlot
    {
        public Item Item { get; private set; }
        public int Quantity { get; private set; }
        public bool IsEmpty => Item == null || Quantity <= 0;
        
        public bool CanAddItem(Item item, int quantity)
        {
            if (IsEmpty) return true;
            return Item.Id == item.Id && 
                   Quantity + quantity <= Item.MaxStackSize;
        }
        
        public void AddItem(Item item, int quantity)
        {
            if (IsEmpty)
            {
                Item = item;
                Quantity = quantity;
            }
            else if (Item.Id == item.Id)
            {
                Quantity += quantity;
            }
        }
        
        public void RemoveItem(int quantity)
        {
            Quantity = Math.Max(0, Quantity - quantity);
            if (Quantity == 0) Item = null;
        }
    }
}

Repository Interfaces


// Domain/Interfaces/IItemRepository.cs
namespace Domain.Interfaces
{
    public interface IItemRepository
    {
        Task GetItemAsync(string itemId);
        Task<List> GetAllItemsAsync();
    }
}

// Domain/Interfaces/IInventoryRepository.cs
namespace Domain.Interfaces
{
    public interface IInventoryRepository
    {
        Task SaveInventoryAsync(List slots);
        Task<List> LoadInventoryAsync();
    }
}

Use Cases


// Domain/UseCases/AddItemToInventoryUseCase.cs
namespace Domain.UseCases
{
    public class AddItemToInventoryUseCase
    {
        private readonly IItemRepository _itemRepository;
        private readonly IInventoryRepository _inventoryRepository;
        
        public AddItemToInventoryUseCase(
            IItemRepository itemRepository, 
            IInventoryRepository inventoryRepository)
        {
            _itemRepository = itemRepository;
            _inventoryRepository = inventoryRepository;
        }
        
        public async Task ExecuteAsync(string itemId, int quantity)
        {
            var item = await _itemRepository.GetItemAsync(itemId);
            if (item == null) return false;
            
            var inventory = await _inventoryRepository.LoadInventoryAsync();
            
            // Try to add to existing slots first
            foreach (var slot in inventory.Where(s => !s.IsEmpty))
            {
                if (slot.CanAddItem(item, quantity))
                {
                    slot.AddItem(item, quantity);
                    await _inventoryRepository.SaveInventoryAsync(inventory);
                    return true;
                }
            }
            
            // Find empty slot
            var emptySlot = inventory.FirstOrDefault(s => s.IsEmpty);
            if (emptySlot != null)
            {
                emptySlot.AddItem(item, quantity);
                await _inventoryRepository.SaveInventoryAsync(inventory);
                return true;
            }
            
            return false; // Inventory full
        }
    }
}

[ADD IMAGE - Domain Layer Structure]
Alt: Visual representation of Domain layer components showing entities, use cases, and interfaces


4) Data Layer: External Dependencies

The Data layer implements the repository interfaces defined in the Domain layer and handles external data sources like files, databases, or web APIs.


// Data/DataSources/JsonItemDataSource.cs
namespace Data.DataSources
{
    [Serializable]
    public class ItemData
    {
        public string id;
        public string name;
        public string description;
        public int maxStackSize;
    }
    
    public class JsonItemDataSource
    {
        private readonly string _dataPath;
        
        public JsonItemDataSource(string dataPath)
        {
            _dataPath = dataPath;
        }
        
        public async Task<List> LoadItemsAsync()
        {
            if (!File.Exists(_dataPath)) return new List();
            
            var json = await File.ReadAllTextAsync(_dataPath);
            var itemDataArray = JsonUtility.FromJson<ItemData[]>(json);
            return itemDataArray?.ToList() ?? new List();
        }
    }
}

// Data/Repositories/ItemRepository.cs
namespace Data.Repositories
{
    public class ItemRepository : IItemRepository
    {
        private readonly JsonItemDataSource _dataSource;
        private readonly Dictionary<string, Item> _itemCache;
        
        public ItemRepository(JsonItemDataSource dataSource)
        {
            _dataSource = dataSource;
            _itemCache = new Dictionary<string, Item>();
        }
        
        public async Task GetItemAsync(string itemId)
        {
            if (_itemCache.TryGetValue(itemId, out var cachedItem))
                return cachedItem;
            
            var itemsData = await _dataSource.LoadItemsAsync();
            var itemData = itemsData.FirstOrDefault(i => i.id == itemId);
            
            if (itemData == null) return null;
            
            var item = new Item(
                itemData.id, 
                itemData.name, 
                itemData.description, 
                itemData.maxStackSize
            );
            
            _itemCache[itemId] = item;
            return item;
        }
        
        public async Task<List> GetAllItemsAsync()
        {
            var itemsData = await _dataSource.LoadItemsAsync();
            return itemsData.Select(data => new Item(
                data.id, 
                data.name, 
                data.description, 
                data.maxStackSize
            )).ToList();
        }
    }
}

// Data/Repositories/PlayerPrefsInventoryRepository.cs
namespace Data.Repositories
{
    public class PlayerPrefsInventoryRepository : IInventoryRepository
    {
        private const string INVENTORY_KEY = "player_inventory";
        
        public async Task SaveInventoryAsync(List slots)
        {
            var inventoryData = slots.Select(slot => new {
                itemId = slot.Item?.Id ?? "",
                quantity = slot.Quantity
            }).ToArray();
            
            var json = JsonUtility.ToJson(new { inventory = inventoryData });
            PlayerPrefs.SetString(INVENTORY_KEY, json);
            PlayerPrefs.Save();
            
            await Task.CompletedTask;
        }
        
        public async Task<List> LoadInventoryAsync()
        {
            var json = PlayerPrefs.GetString(INVENTORY_KEY, "");
            if (string.IsNullOrEmpty(json))
            {
                // Return empty inventory with 20 slots
                return Enumerable.Repeat(new InventorySlot(), 20).ToList();
            }
            
            // Parse and reconstruct inventory
            // Implementation details...
            
            await Task.CompletedTask;
            return new List();
        }
    }
}

Performance Note: Consider implementing caching strategies in your repositories to avoid frequent file I/O operations


5) Presentation Layer: Unity-Specific Code

The Presentation layer contains MonoBehaviours, UI controllers, and Unity-specific code. It communicates with the Domain layer through use cases.

View Interfaces


// Presentation/Views/IInventoryView.cs
namespace Presentation.Views
{
    public interface IInventoryView
    {
        void DisplayInventory(List slots);
        void ShowMessage(string message);
        void SetLoading(bool isLoading);
        
        event Action OnSlotClicked;
        event Action<string, int> OnItemAdded;
    }
}

Presenters


// Presentation/Presenters/InventoryPresenter.cs
namespace Presentation.Presenters
{
    public class InventoryPresenter
    {
        private readonly IInventoryView _view;
        private readonly AddItemToInventoryUseCase _addItemUseCase;
        private readonly GetInventoryUseCase _getInventoryUseCase;
        
        public InventoryPresenter(
            IInventoryView view,
            AddItemToInventoryUseCase addItemUseCase,
            GetInventoryUseCase getInventoryUseCase)
        {
            _view = view;
            _addItemUseCase = addItemUseCase;
            _getInventoryUseCase = getInventoryUseCase;
            
            _view.OnItemAdded += OnItemAdded;
        }
        
        public async void Initialize()
        {
            _view.SetLoading(true);
            var inventory = await _getInventoryUseCase.ExecuteAsync();
            _view.DisplayInventory(inventory);
            _view.SetLoading(false);
        }
        
        private async void OnItemAdded(string itemId, int quantity)
        {
            _view.SetLoading(true);
            var success = await _addItemUseCase.ExecuteAsync(itemId, quantity);
            
            if (success)
            {
                var inventory = await _getInventoryUseCase.ExecuteAsync();
                _view.DisplayInventory(inventory);
                _view.ShowMessage("Item added successfully!");
            }
            else
            {
                _view.ShowMessage("Could not add item. Inventory might be full.");
            }
            
            _view.SetLoading(false);
        }
    }
}

MonoBehaviour Implementation


// Presentation/MonoBehaviours/InventoryViewController.cs
namespace Presentation.MonoBehaviours
{
    public class InventoryViewController : MonoBehaviour, IInventoryView
    {
        [SerializeField] private Transform _slotsContainer;
        [SerializeField] private GameObject _slotPrefab;
        [SerializeField] private Text _messageText;
        [SerializeField] private GameObject _loadingPanel;
        
        public event Action OnSlotClicked;
        public event Action<string, int> OnItemAdded;
        
        private InventoryPresenter _presenter;
        private List _slotViews = new List();
        
        public void Initialize(InventoryPresenter presenter)
        {
            _presenter = presenter;
            CreateSlotViews();
            _presenter.Initialize();
        }
        
        public void DisplayInventory(List slots)
        {
            for (int i = 0; i < slots.Count && i < _slotViews.Count; i++)
            {
                _slotViews[i].DisplaySlot(slots[i]);
            }
        }
        
        public void ShowMessage(string message)
        {
            _messageText.text = message;
            // Hide message after 3 seconds
            CancelInvoke(nameof(ClearMessage));
            Invoke(nameof(ClearMessage), 3f);
        }
        
        public void SetLoading(bool isLoading)
        {
            _loadingPanel.SetActive(isLoading);
        }
        
        private void CreateSlotViews()
        {
            for (int i = 0; i < 20; i++) // 20 inventory slots
            {
                var slotGO = Instantiate(_slotPrefab, _slotsContainer);
                var slotView = slotGO.GetComponent();
                slotView.Initialize(i);
                slotView.OnClicked += (index) => OnSlotClicked?.Invoke(index);
                _slotViews.Add(slotView);
            }
        }
        
        private void ClearMessage()
        {
            _messageText.text = "";
        }
        
        // Test method - remove in production
        [ContextMenu("Add Test Item")]
        private void AddTestItem()
        {
            OnItemAdded?.Invoke("sword_001", 1);
        }
    }
}

[ADD IMAGE - UI Implementation]
Alt: Unity UI showing inventory grid with item slots and loading indicator


6) Dependency Injection Setup

Use a simple DI container to wire up dependencies. We'll create a lightweight service locator pattern.


// Infrastructure/DI/ServiceContainer.cs
namespace Infrastructure.DI
{
    public class ServiceContainer
    {
        private static ServiceContainer _instance;
        public static ServiceContainer Instance => _instance ??= new ServiceContainer();
        
        private readonly Dictionary<Type, object> _services = new();
        
        public void Register(T service)
        {
            _services[typeof(T)] = service;
        }
        
        public T Resolve()
        {
            if (_services.TryGetValue(typeof(T), out var service))
            {
                return (T)service;
            }
            
            throw new InvalidOperationException($"Service of type {typeof(T)} not registered");
        }
        
        public void Clear()
        {
            _services.Clear();
        }
    }
}

// Infrastructure/GameBootstrapper.cs
namespace Infrastructure
{
    public class GameBootstrapper : MonoBehaviour
    {
        [SerializeField] private InventoryViewController _inventoryView;
        [SerializeField] private string _itemsDataPath = "StreamingAssets/items.json";
        
        private async void Start()
        {
            await SetupDependencies();
            InitializePresenters();
        }
        
        private async Task SetupDependencies()
        {
            var container = ServiceContainer.Instance;
            
            // Data layer
            var itemDataSource = new JsonItemDataSource(
                Path.Combine(Application.streamingAssetsPath, "items.json")
            );
            var itemRepository = new ItemRepository(itemDataSource);
            var inventoryRepository = new PlayerPrefsInventoryRepository();
            
            container.Register(itemRepository);
            container.Register(inventoryRepository);
            
            // Domain layer (Use cases)
            var addItemUseCase = new AddItemToInventoryUseCase(
                itemRepository, 
                inventoryRepository
            );
            var getInventoryUseCase = new GetInventoryUseCase(inventoryRepository);
            
            container.Register(addItemUseCase);
            container.Register(getInventoryUseCase);
        }
        
        private void InitializePresenters()
        {
            var container = ServiceContainer.Instance;
            
            var inventoryPresenter = new InventoryPresenter(
                _inventoryView,
                container.Resolve(),
                container.Resolve()
            );
            
            _inventoryView.Initialize(inventoryPresenter);
        }
    }
}

Alternative: For larger projects, consider using established DI frameworks like VContainer or Zenject for more advanced features


7) Practical Example: Inventory System

Let's see how all layers work together in our inventory system example. Here's the complete data flow:

  1. User Action: Player clicks "Add Item" button
  2. View: InventoryViewController captures the event
  3. Presenter: InventoryPresenter calls the use case
  4. Use Case: AddItemToInventoryUseCase orchestrates the business logic
  5. Repository: Data is saved via IInventoryRepository
  6. View Update: UI is refreshed with new inventory state

Sample Items Data (StreamingAssets/items.json)


[
  {
    "id": "sword_001",
    "name": "Iron Sword",
    "description": "A sturdy iron sword for combat",
    "maxStackSize": 1
  },
  {
    "id": "potion_health",
    "name": "Health Potion",
    "description": "Restores 50 HP",
    "maxStackSize": 10
  },
  {
    "id": "coin_gold",
    "name": "Gold Coin",
    "description": "Standard currency",
    "maxStackSize": 999
  }
]

[ADD IMAGE - Complete Flow]
Alt: Sequence diagram showing data flow from UI click to database save and back to UI update


8) Testing Your Architecture

One of the biggest advantages of Clean Architecture is testability. Here's how to test different layers:

Domain Layer Tests


// Tests/Domain/InventorySlotTests.cs
[TestFixture]
public class InventorySlotTests
{
    [Test]
    public void CanAddItem_EmptySlot_ReturnsTrue()
    {
        // Arrange
        var slot = new InventorySlot();
        var item = new Item("test", "Test Item", "Description", 10);
        
        // Act
        var result = slot.CanAddItem(item, 5);
        
        // Assert
        Assert.IsTrue(result);
    }
    
    [Test]
    public void AddItem_ToEmptySlot_SetsItemAndQuantity()
    {
        // Arrange
        var slot = new InventorySlot();
        var item = new Item("test", "Test Item", "Description", 10);
        
        // Act
        slot.AddItem(item, 5);
        
        // Assert
        Assert.AreEqual(item, slot.Item);
        Assert.AreEqual(5, slot.Quantity);
    }
}

Use Case Tests with Mocks


[TestFixture]
public class AddItemToInventoryUseCaseTests
{
    private Mock _itemRepository;
    private Mock _inventoryRepository;
    private AddItemToInventoryUseCase _useCase;
    
    [SetUp]
    public void Setup()
    {
        _itemRepository = new Mock();
        _inventoryRepository = new Mock();
        _useCase = new AddItemToInventoryUseCase(_itemRepository.Object, _inventoryRepository.Object);
    }
    
    [Test]
    public async Task ExecuteAsync_ValidItem_ReturnsTrue()
    {
        // Arrange
        var item = new Item("sword", "Sword", "A sword", 1);
        var inventory = new List { new InventorySlot() };
        
        _itemRepository.Setup(x => x.GetItemAsync("sword")).ReturnsAsync(item);
        _inventoryRepository.Setup(x => x.LoadInventoryAsync()).ReturnsAsync(inventory);
        
        // Act
        var result = await _useCase.ExecuteAsync("sword", 1);
        
        // Assert
        Assert.IsTrue(result);
        _inventoryRepository.Verify(x => x.SaveInventoryAsync(It.IsAny<List>()), Times.Once);
    }
}

Test Framework Setup: Install NUnit and Moq packages via Package Manager for comprehensive testing capabilities


9) Performance Considerations

When implementing Clean Architecture in Unity, keep these performance aspects in mind:

Memory Management

  • Object Pooling: Pool frequently created objects like UI elements
  • Async/Await: Use ConfigureAwait(false) for non-Unity thread operations
  • Caching: Cache frequently accessed data in repositories

Optimization Tips


// Optimized repository with caching
public class OptimizedItemRepository : IItemRepository
{
    private readonly Dictionary<string, Item> _cache = new();
    private readonly JsonItemDataSource _dataSource;
    private bool _isLoaded = false;
    
    public async Task GetItemAsync(string itemId)
    {
        if (!_isLoaded)
        {
            await LoadAllItems();
        }
        
        return _cache.TryGetValue(itemId, out var item) ? item : null;
    }
    
    private async Task LoadAllItems()
    {
        var itemsData = await _dataSource.LoadItemsAsync();
        foreach (var itemData in itemsData)
        {
            var item = new Item(itemData.id, itemData.name, itemData.description, itemData.maxStackSize);
            _cache[item.Id] = item;
        }
        _isLoaded = true;
    }
}

Profiling: Always use Unity Profiler to identify actual bottlenecks rather than premature optimization


Common Mistakes

  • Tight Coupling: Don't reference Unity classes directly in Domain layer
  • God Classes: Keep use cases focused on single responsibilities
  • Over-Engineering: Start simple and add complexity as needed
  • Ignoring Assembly Definitions: Use asmdef files to enforce layer separation
  • Synchronous File I/O: Always use async methods for file operations
  • Missing Error Handling: Implement proper exception handling in all layers

Conclusion

Clean Architecture in Unity provides a robust foundation for scalable game development. By separating concerns into distinct layers, you create maintainable, testable code that can grow with your project. The inventory system example demonstrates how business logic remains independent of Unity-specific implementation details.

Start implementing Clean Architecture in your next Unity project and experience the benefits of organized, testable code. Your future self (and team members) will thank you!

Next Steps: Try implementing this architecture with your own game systems, add automated testing, and explore dependency injection frameworks for larger projects.


Resources

SEO Mini-Checklist

  • ✅ Focus keyword "Unity Clean Architecture" in title and first paragraph
  • ✅ Secondary keywords: "multi-module", "dependency injection", "Unity assembly definitions"
  • ✅ Internal linking opportunities: Unity best practices, testing guides
  • ✅ External linking: Official Unity documentation, Clean Architecture resources

Additional Resources


Note: This document works directly in WYSIWYG editors and provides a complete working example for Unity Clean Architecture implementation.

This article was updated on August 15, 2025