Clean Architecture & Domain-Driven Design

The Problem

In the early stages of development, all game logic was stuffed directly into MonoBehaviour components. This led to a state where logic and visuals were completely entangled — difficult to test, difficult to debug, and impossible to verify without launching Unity.

The Solution: Clear Layer Separation

The project is divided into 2 layers with an absolute boundary between them:

Layer 1 — Domain Layer (Pure C#)

Contains all business rules, completely independent of Unity (no UnityEngine imports):

  • Entities: Enemy, Tower, HexMap — objects holding state and core logic.
  • Value Objects: Position, ActiveEffect — immutable data describing attributes.
  • Services: EnemyService, PathfindingService — executing business logic.
// Enemy.cs — Pure C# Entity
public class Enemy
{
    public float CurrentHealth { get; private set; }
    public float BaseSpeed { get; private set; }
    public float SpeedModifier { get; private set; } = 1f;
    public float CurrentSpeed => BaseSpeed * SpeedModifier;
    
    public Position Position { get; set; } // Value Object
    public event Action<float, float> OnHealthChanged;

    public void TakeDamage(float amount)
    {
        CurrentHealth -= amount;
        // Notifies the outside world without knowing who is listening
        OnHealthChanged?.Invoke(CurrentHealth, MaxHealth);
    }
}

Layer 2 — Unity Infrastructure Layer

Integration with the Unity Engine — responsible only for display and Input handling:

  • Views: EnemyView, TowerViewMonoBehaviour components.
  • Adapters: LayoutAdapter — converts Hex coordinates to Unity’s 3D world space.
// EnemyView.cs — Unity View Layer
public class EnemyView : MonoBehaviour
{
    private Enemy _enemy; // Reference to the Domain Entity
    [SerializeField] private FloatingHealthBar floatingHealthBar; // Dedicated UI component

    public void Initialize(Enemy enemy, LayoutAdapter layout)
    {
        _enemy = enemy;
        // Wire the Domain Event to a dedicated UI component (SRP)
        _enemy.OnHealthChanged += floatingHealthBar.UpdateHealth;
    }

    private void Update()
    {
        // Sync position from Domain to Unity World Space via Adapter
        transform.position = _layoutAdapter.HexToWorld(_enemy.CurrentTile);
        
        // Sync Animation speed based on the actual Speed ratio from Domain
        float animSpeed = (_enemy.CurrentSpeed / stepDistance) * scaleFactor;
        _animator.SetFloat("Speed", animSpeed);
    }
}

Real-World Benefits

  • Faster debugging: Immediately know whether a bug is in logic (Domain) or visuals (View)
  • Testable: The entire Domain Layer can run Unit Tests from the command line, no Unity Editor needed
  • Reusable: 80% of core logic is independent — can be reused or migrated to another engine