FIVE letters, TEN intimidating words, and a bunch of academic jargon that make your eyes glaze over before you even get to the point. Somewhere along the way, "SOLID principles" got a reputation for being something only enterprise developers care about in between coffee breaks and standup meetings.
Here's the thing though: you've probably already broken (or followed) most of these principles in your Unity projects without knowing they had names. SOLID isn't some separate discipline you need to learn, it's just a handful of common-sense guidelines for not making your future self miserable.
Let's go through all five, Unity-style!
Quick Overview
S - Single Responsibility → one script, one job, 🎶let's get together and feeel...🎶O - Open/Closed → extend it, don't rewrite it L - Liskov Substitution → subclasses shouldn't lie I - Interface Segregation → don't force-feed unused methods D - Dependency Inversion → depend on abstractions, not concrete classes
That's the whole thing. Everything below is just unpacking what those actually mean when you're staring at a PlayerController.cs file that's grown to 900 lines and somehow handles movement, inventory, dialogue, and saving the game.
S - Single Responsibility Principle
A class should have one reason to change. Just one.
We've all written THE monster script, you know the one:
public class Player : MonoBehaviour
{
void Move() { }
void Attack() { }
void SaveGame() { }
void PlayFootstepSound() { }
void UpdateInventoryUI() { }
void HandleDialogue() { }
}
Six responsibilities in one class, why not. Change how saving works, and you're editing the same file that handles combat. Touch dialogue, and now you're nervously scrolling past movement code hoping you don't break anything.
The fix isn't complicated, just split it up:
Player
↓
Movement.cs
CombatController.cs
SaveSystem.cs
InventoryUI.cs
DialogueHandler.cs
Each script does one thing. Each script can be tested, changed, or replaced without dragging four other systems along with it.
O - Open/Closed Principle
Code should be open for extension, but closed for modification. So, you should be able to add new behavior without rewriting what already works.
Classic violation exam,ple: the giant switch statement:
public void DealDamage(string enemyType)
{
if (enemyType == "Goblin") { /* logic */ }
else if (enemyType == "Orc") { /* logic */ }
else if (enemyType == "Dragon") { /* logic */ }
// and now you need a Skeleton, so back in here we go...
}
Every new enemy means editing this method again, and hoping you don't accidentally break Orc logic while adding Skeletons. This method is never actually "done",.. it just keeps growing forever.
Instead, let each enemy define its own behavior:
public abstract class Enemy : MonoBehaviour
{
public abstract void DealDamage();
}
public class Goblin : Enemy
{
public override void DealDamage() { /* Goblin-specific logic */ }
}
public class Dragon : Enemy
{
public override void DealDamage() { /* Dragon-specific logic, presumably more dramatic */ }
}
Need a new enemy? Add a new class. The existing ones stay untouched, unbroken, and blissfully unaware anything changed.
L - Liskov Substitution Principle
This one sounds the most academic, but it's actually really simple: if B is a subclass of A, you should be able to use B anywhere A is expected, without anything breaking. Let me try to make this more clear:
Here's a violation that trips people up constantly:
public class Bird
{
public virtual void Fly() { }
}
public class Penguin : Bird
{
public override void Fly()
{
throw new NotImplementedException("Penguins can't fly!");
}
}
Technically yes this compiles, but in reality it's a landmine. Anything treating Penguin as a Bird and calling Fly() is going to explode at runtime, and whoever wrote that calling code had every right to assume it would just work and that's the whole point of inheritance.
The fix is to stop pretending Penguin belongs in the same hierarchy as things that fly BECAUSE THEY DON'T, ok:
public abstract class Bird { }
public interface IFlyable
{
void Fly();
}
public class Sparrow : Bird, IFlyable
{
public void Fly() { }
}
public class Penguin : Bird
{
// no Fly method, no lies, no exceptions waiting to happen
}
If a subclass needs to override a method just to throw an exception or do nothing, that's usually a sign it shouldn't be a subclass in the first place.
I - Interface Segregation Principle
Don't force a class to implement unnecessary methods. Big interfaces are basically a way of guaranteeing someone, somewhere, will implement a method by just leaving it empty and hoping nobody notices.
public interface ICharacter
{
void Move();
void Attack();
void CastSpell();
void OpenInventory();
}
public class TrainingDummy : ICharacter
{
public void Move() { }
public void Attack() { }
public void CastSpell() { } // empty. dummies don't cast spells.
public void OpenInventory() { } // also empty. it's a dummy, not a wizard.
}
That TrainingDummy is implementing methods it will never use, purely because the interface demanded it. Multiply that by a dozen enemy types and you've got empty method bodies scattered everywhere, silently lying about what each class actually does.
Split the interface into smaller, focused pieces instead:
public interface IMovable { void Move(); }
public interface IAttacker { void Attack(); }
public interface ISpellCaster { void CastSpell(); }
public class TrainingDummy : IMovable, IAttacker
{
public void Move() { }
public void Attack() { }
}
Now TrainingDummy only implements what it actually does. No empty methods pretending to be functionality.
D - Dependency Inversion Principle
High-level systems shouldn't depend directly on low-level ones, both should depend on abstractions. This is the one that sounds the most confusing on paper and is actually the most useful in practice.
Here's the trap:
public class Player : MonoBehaviour
{
private SwordWeapon weapon = new SwordWeapon();
public void Attack()
{
weapon.Swing();
}
}
Player is now hard-locked to SwordWeapon. Want a bow instead? You're editing Player directly. Want to swap weapons mid-game? Also editing Player. Every new weapon type means going back into a class that shouldn't need to care about weapon implementation details at all.
Depend on an interface instead:
public interface IWeapon
{
void Attack();
}
public class Sword : IWeapon
{
public void Attack() { Debug.Log("Slash!"); }
}
public class Bow : IWeapon
{
public void Attack() { Debug.Log("Twang!"); }
}
public class Player : MonoBehaviour
{
public IWeapon weapon;
public void Attack()
{
weapon.Attack();
}
}
Now Player doesn't know or care what kind of weapon it's holding. Drag in a Sword, a Bow, or whatever you build next month, Player never changes. This is also exactly the kind of relationship that pairs nicely with ScriptableObjects for swappable configuration, if you want to take it a step further.
Should You Follow SOLID Religiously?
No, not really. Keep it in mind when coding, and honestly, treating it like a checklist you must satisfy on every script is how people end up over-engineering a script that spawns a coin pickup into six interfaces and three abstract classes. Eventually yuor game ends up with its own game-architecture, and every programmer has its own way of doing this. The more you code, the more you implement your own coding-architecture. But sure, there are some general guidelines;
Small prototype → don't worry about it
Growing mid-size game → start applying S and D, they save the most pain
Large, long-term project → maybe all five, consistently, especially before your team doubles in size
SOLID isn't a law. It's a set of warning signs. When a script keeps growing forever (violates S), or you're editing five files to add one enemy (violates O), that's usually your cue that one of these principles would help, not a mandate to architect a mobile game jam project like it's enterprise banking software.
Wrapping Up
None of this is academic (once you see it in Unity terms I guess). Single Responsibility keeps your scripts from turning into 1000-line monsters. Open/Closed stops you from rewriting the same method every time you add content. Liskov Substitution keeps your inheritance honest. Interface Segregation keeps your classes from faking functionality they don't have. Dependency Inversion keeps your systems swappable instead of welded together. Summarizing it like this makes it a lot easier to understand.
You don't need to memorize it all. You just need to notice when a script's getting a little too comfortable doing everyone else's job, and split it up before it becomes the file nobody wants to touch - including your future self.
~Happy Coding!

No comments:
Post a Comment