Introduction
In a previous post: The Liskov Substitution Principle, I mention that “A type for an outside observer is actually defined by its behaviours”. This holds true for users of the type—but what about the creators of a type? How should we approach the problem of defining new types, which likely accounts for 99% of our coding in OOP when building new software?
While much of programming time is spent debugging and testing, the creation of a type is crucial, not only because it defines the functionality of our project but also because its implementation impacts how easily we can test, debug, and refine that functionality.
Unlike a user of a type, who is primarily concerned with its behavior rather than its implementation, a type’s creator must focus on the data rather than the behaviors. Let’s explore how we should approach writing code that effectively solves problems by first understanding what the real problem is.
Thinking About Data
In object-oriented programming (OOP), everything revolves around data transformation. Every problem we encounter involves some form of input data and an expected output. This is the core issue: understanding what data we have coming in and what data we want to produce.
Code is not the problem, data is the problem and code is the solution to that problem.
Once we recognize this, we can shift our approach to writing code. Before we start coding, we should already have a clear understanding of the problem: the data being processed and the expected transformation. Only with this understanding can we craft an effective solution, our code.
Writing code without first defining the data and its transformation is like trying to solve a problem we haven’t properly identified.
The correct approach to any OOP program is to strictly define the problem, which is the data, before writing a single line of code. Once this is done, implementing the solution becomes much easier, avoiding common pitfalls such as the XY problem situations.
By treating code as the solution rather than the problem, we not only simplify the development process but also create code that is easier to debug, test, and modify. This is because our code becomes more structured and easier to reason about.
Now, let’s see how this principle works in practice with an example.
An Example With Code
Let’s suppose we have the following problem: we need to create a currency system for a game.
In this game, there are three major civilizations—Dwarves, Goblins, and Humans—each with its own currency. The Dwarves use Dwarf Coins, the Goblins use Goblin Coins, and the Humans have a more complex system with three types of currency: Gold, Silver, and Copper coins.
There will be exchange rates between these currencies. For example:
- 1 Dwarf Coin is equivalent to 17 Human Gold Coins.
- 1 Goblin Coin is worth 3 Human Copper Coins.
- For Humans, currency follows a strict conversion system:
- 1 Gold Coin = 10 Silver Coins
- 1 Silver Coin = 10 Copper Coins
The last conversion should happen automatically: the player should never have a two-digit amount of Silver or Copper Coins. For instance, if a player has 9 Copper Coins and earns one more, they should automatically have 1 Silver Coin instead of 10 Copper Coins.
Now, let’s explore how we can design a system that accurately tracks a player’s total wealth while ensuring proper currency conversions based on exchange rates.
Avoiding a Common Pitfall
When faced with a problem like this, it’s tempting to start coding right away; creating variables for different coin types, writing methods to adjust these values based on exchange rates, and adding safeguards to keep the data “synchronized.”
For example, imagine a player has 17 Gold Coins and 1 Copper Coin. If they convert this to Dwarf Money, they should receive 1 Dwarf Coin. But when converting back to Human currency, they must still have 17 Gold Coins and 1 Copper Coin, not just 17 Gold Coins.
Manually tracking these different coin types while ensuring proper synchronization would be unnecessarily complex. This approach would not only make our code harder to test but also increase the risk of bugs and make debugging more difficult. Instead of jumping straight into implementation, let’s take a step back and think in terms of data before writing any code.
Defining the Real Problem
The different types of money in our system are not separate data points; they are simply different representations of a single value. Instead of treating Dwarf money, Goblin money, Human Gold, Silver, and Copper as separate variables, we should recognize that the only real data we need is a single variable. We don’t need to define different data for each different type of coin, this is not the problem. This is code that actually belongs to the solution.
The real problem is that we have only one variable that represents the amount of money the player has in any currency. The only mutable variable we need is one variable called amount
that will will store the total wealth of the player in a universal format, and all other currency types will be derived from it dynamically. By doing this, we eliminate unnecessary state management, reduce complexity, and create a system that is much easier to maintain, test, and debug.
So, let’s first create a class called Money
that has only one mutable field, called _amount
:
public class Money
{
private enum MoneyType
{
Goblin,
Dwarf,
Gold,
Silver
}
private static IReadOnlyDictionary<MoneyType, int> ExchangeRateToCopper =>
new Dictionary<MoneyType, int>
{
{ MoneyType.Dwarf, 1700 },
{ MoneyType.Goblin, 3 },
{ MoneyType.Gold, 100 },
{ MoneyType.Silver, 10 }
};
private int _amount; // This is the only mutable field.
}
Along with that variable, we also created an enum that has the different types of money that we want, and an immutable dictionary that holds the exchange rates.
Our _amount
variable will only hold the amount of coppers our player has. This decision is based on the value of coppers: because coppers have the least value from any other coin type we can represent them with integers.
Let’s start adding behaviors now that will solve our problem: return the appropriate amount of money the player has in each money type. For example we can create the DwarfMoney
class that is nested within our type:
public class DwarfMoney(Money moneyInstance)
{
public int Amount => (int)Math.Floor((float)moneyInstance._amount / ExchangeRateToCopper[MoneyType.Dwarf]);
public void Add(int amount) => moneyInstance._amount += amount * ExchangeRateToCopper[MoneyType.Dwarf];
public void Remove(int amount) => moneyInstance._amount -= amount * ExchangeRateToCopper[MoneyType.Dwarf];
}
This is pretty simple now, all we have to do is the appropriate transformation on each operation, without the need to add any new state. The amount of Dwarf Money doesn’t need to have an internal representation in our class, we just need to know its value. The same is true for the Goblin money:
public class GoblinMoney(Money moneyInstance)
{
public int Amount => (int)Math.Floor((float)moneyInstance._amount / ExchangeRateToCopper[MoneyType.Goblin]);
public void Add(int amount) => moneyInstance._amount += amount * ExchangeRateToCopper[MoneyType.Goblin];
public void Remove(int amount) => moneyInstance._amount -= amount * ExchangeRateToCopper[MoneyType.Goblin];
}
For the human money, the logic is the same, what is different are the implementations of our behaviors because of the more complex monetary system the humans have, but these are just behaviors, no new state needs to be created:
public class HumanMoney(Money moneyInstance)
{
public int Gold => moneyInstance._amount / ExchangeRateToCopper[MoneyType.Gold];
public int Silver => moneyInstance._amount / ExchangeRateToCopper[MoneyType.Silver] % ExchangeRateToCopper[MoneyType.Silver];
public int Copper => moneyInstance._amount % ExchangeRateToCopper[MoneyType.Silver];
public void Add(int gold = 0, int silver = 0, int copper = 0) => moneyInstance._amount += gold * 100 + silver * 10 + copper;
public void Remove(int gold = 0, int silver = 0, int copper = 0) => moneyInstance._amount -= gold * 100 + silver * 10 + copper;
}
Here’s the complete class for reference:
public class Money
{
public DwarfMoney Dwarf { get; }
public GoblinMoney Goblin { get; }
public HumanMoney Human { get; }
private enum MoneyType
{
Goblin,
Dwarf,
Gold,
Silver
}
private static IReadOnlyDictionary<MoneyType, int> ExchangeRateToCopper =>
new Dictionary<MoneyType, int>
{
{ MoneyType.Dwarf, 1700 },
{ MoneyType.Goblin, 3 },
{ MoneyType.Gold, 100 },
{ MoneyType.Silver, 10 }
};
private int _amount; // This is the only mutable field.
public Money()
{
Dwarf = new DwarfMoney(this);
Goblin = new GoblinMoney(this);
Human = new HumanMoney(this);
}
public class DwarfMoney(Money moneyInstance)
{
public int Amount => (int)Math.Floor((float)moneyInstance._amount / ExchangeRateToCopper[MoneyType.Dwarf]);
public void Add(int amount) => moneyInstance._amount += amount * ExchangeRateToCopper[MoneyType.Dwarf];
public void Remove(int amount) => moneyInstance._amount -= amount * ExchangeRateToCopper[MoneyType.Dwarf];
}
public class GoblinMoney(Money moneyInstance)
{
public int Amount => (int)Math.Floor((float)moneyInstance._amount / ExchangeRateToCopper[MoneyType.Goblin]);
public void Add(int amount) => moneyInstance._amount += amount * ExchangeRateToCopper[MoneyType.Goblin];
public void Remove(int amount) => moneyInstance._amount -= amount * ExchangeRateToCopper[MoneyType.Goblin];
}
public class HumanMoney(Money moneyInstance)
{
public int Gold => moneyInstance._amount / ExchangeRateToCopper[MoneyType.Gold];
public int Silver => moneyInstance._amount / ExchangeRateToCopper[MoneyType.Silver] % ExchangeRateToCopper[MoneyType.Silver];
public int Copper => moneyInstance._amount % ExchangeRateToCopper[MoneyType.Silver];
public void Add(int gold = 0, int silver = 0, int copper = 0) => moneyInstance._amount += gold * 100 + silver * 10 + copper;
public void Remove(int gold = 0, int silver = 0, int copper = 0) => moneyInstance._amount -= gold * 100 + silver * 10 + copper;
}
}
This class has the advantage of containing only a single mutable field that represents its state, and this field is private. There is no need for logic to handle multiple states, the class’s state cannot be modified directly by external code but only through its internal behaviors, making it easy to reason about.
Any bugs will be easy to identify since each method performs a single transformation on just one variable. Additionally, the class can never be in an invalid state. For example, due to a coding mistake, we cannot end up with 1 Dwarf Coin, or 16 Human Gold Coins and 9 Silver Coins, such inconsistencies are impossible. Any incorrect operation will be isolated within the relevant method and can only affect the single variable we maintain.
The design also makes it easy to extend with new coin types. To introduce a new currency, we simply add a nested class that handles the necessary operations, without requiring any additional state variables.
This type, is very easy to use:
Money money = new();
money.Goblin.Add(560);
money.Dwarf.Add(1);
Console.WriteLine(money.Dwarf.Amount);
Console.WriteLine(money.Goblin.Amount);
Console.WriteLine(money.Human.Gold);
Console.WriteLine(money.Human.Silver);
Console.WriteLine(money.Human.Copper);
Console.WriteLine();
money.Goblin.Remove(7);
Console.WriteLine(money.Dwarf.Amount);
Console.WriteLine(money.Goblin.Amount);
Console.WriteLine(money.Human.Gold);
Console.WriteLine(money.Human.Silver);
Console.WriteLine(money.Human.Copper);
Console.WriteLine();
money.Human.Add(16,10);
Console.WriteLine(money.Dwarf.Amount);
Console.WriteLine(money.Goblin.Amount);
Console.WriteLine(money.Human.Gold);
Console.WriteLine(money.Human.Silver);
Console.WriteLine(money.Human.Copper);
And its callers can use different types of money without knowing anything about how money is internally represented within the class.
Of course, this class is not yet production-ready. It lacks checks for money removal, meaning negative values are possible. However, implementing these checks is straightforward and game-specific: this behavior will depend on how the game should respond when certain thresholds are met. Regardless of these additional checks, they will still only need to interact with a single variable: the _amount
integer.
Additionally, this class does not properly encapsulate its nested classes to ensure they are publicly accessible while preventing external code from instantiating them. I did not include that functionality here since it is a C#-specific concern rather than an architectural one. However, I will write a future post exploring different ways to define nested public classes that can only be instantiated by their containing class.
Conclusion
Learning to think in terms of data when defining a problem helps create simpler and more robust solutions.
A common example of violating this principle is using a boolean variable, such as isDead
, to indicate when an entity’s hit points reach zero in a game.
Instead of introducing an additional state variable, we can define a property or method that determines whether the entity is dead based on its hit points:
public int IsDead => hp <=0;
There is no need to introduce extra state variables that depend on other variables. We should design our classes so that impossible states are impossible to represent, even in the presence of mistakes.
For example, if we had an isDead
field, a bug could leave the class in an invalid state where isDead
is true
while hp
remains positive (or vice versa). By avoiding an explicit isDead
field and instead deriving it dynamically based on existing data, we eliminate the risk of such inconsistencies.
Thank you for reading, and if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.