How to design an object-oriented architecture, perfectly?

Emrecan Özkan
5 min readAug 19, 2023
how to design an oop architecture

Object-oriented programming. You must have heard it before. But many people find it complex and difficult. Let’s examine the subject that everyone who develops software should know in a little detail in this article.

Definition: To embody everything that is abstract. It’s that simple.

It’s actually not that difficult. I will try to explain it to you by giving examples one by one. We will also examine the SOLID principles in an applied way. You will not see cat and dog classes in this article. You will see an application from real life.

GitHub Repository: Click Here!
Diagrams: Click Here!

First, let me share with you the object-oriented design I drew.

oop design

This design may seem complicated to you. However, it is one of the simplest designs you will ever see. And it still has more shortcomings. Let me give you a task on this occasion. Let me share the missing parts in the design with me in the comments!

While examining what I am doing now, I would like to talk to you about the SOLID principles. In this way, you will learn how to design an OOP architecture and you will gain practice.

Single Responsibility Principle

A class or object can only be changed for one purpose, which is the responsibility assigned to that class, meaning that a class (which can be a function) has only one job to do.

For example, let me give an example from the project that I designed live. When the player wants to buy a Seed, it calls the BuySeed function.

private void seedsEmporiumList_DoubleClick(object sender, EventArgs e)
{
Seed seed = seedsEmporium.Seeds.ElementAt(seedsEmporiumList.SelectedItems[0].Index);
if (player.BuySeed(seed))
{
if (player.PlantASeed(garden, seed))
{
seedsEmporium.SellASeed(seed);
seedsEmporiumList.Items.Remove(seedsEmporiumList.SelectedItems[0]);
conversationLabel.Text = $"Great, you plant a {seed.GetName()}!";
}
else
{
player.GetRefund(seed.GetPrice());
conversationLabel.Text = "No land plot in garden!";
}
}
else
{
conversationLabel.Text = "No money, no seed!";
}
}
public bool BuySeed(Seed seed)
{
if (Money < seed.GetPrice())
{
return false;
}
Money -= seed.GetPrice();
return true;
}

Then Player plants the seed in the garden.

public bool PlantASeed(Garden garden, Seed seed)
{
if (LandPlots == 0)
{
return false;
}
garden.AddPlant(new Plant(seed));
LandPlots--;
return true;
}

Then SeedsEmporium calls the SellASeed function.

public void SellASeed(Seed seed)
{
Seeds.RemoveAt(Seeds.IndexOf(seed));
}

As you can see, both functions do their job. If I used functions such as OpenTheWallet, and TakeTheMoney within the BuySeed function, this principle would not be suitable. We need to completely separate these functions from each other. Because in this way, we can develop more easily.

Open/Closed Principle

A class or function should preserve existing properties and not allow changes. That is, it should not change its behavior and should be able to acquire new features.

For example, the Player goes to the market before selling HarvestedPlant and completes the selling process there.

public bool GoToMarket()
{
if (Money < 10)
{
return false;
}
Money -= 10;
return true;
}
public bool SellAPlant(Plant plant)
{
if (GoToMarket())
{
Money += plant.GetPlantedSeed().GetHarvestPrice();
Inventory.RemoveHarvestedPlant(plant);
return true;
}
return false;
}

However, GoToMarket and SellAPlant functions do not require rewriting when developed. Just calling another function in the last line will suffice. Because it is currently doing all the work it needs to do correctly.

Liskov Substitution Principle

We should be able to use subclasses instead of the (superior) classes from which they derive, without the need to make any changes in our code.

I will give you one of the most beautiful examples. When you look at the design, you see that there are subclasses that inherit the Seed class. I can easily use the objects of these classes instead of the Seed class. Let’s examine the code below.

private void addPearToEmporiumButton_Click(object sender, EventArgs e)
{
seedsEmporium.AddSeed(new Pear());
seedsEmporiumList.Items.Clear();
ReloadSeedEmporiumList();
}

class SeedsEmporium: ISellASeed
{
public List<Seed> Seeds { get; set; }
public void AddSeed(Seed seed)
{
Seeds.Add(seed);
}
}

As you can see here, even though I don’t send the subclass as a parameter, it is added as a Seed object without any problems. It is not subject to any restrictions.

Interface Segregation Principle

Instead of gathering all the responsibilities into a single interface, we should create more customized interfaces.

For example, I try to use this principle as well as possible. Let’s examine the sample code below.

interface IPurchaseASeed
{
bool BuySeed(Seed seed);
}
interface IPlantASeed
{
bool PlantASeed(Garden garden, Seed seed);
}
class Player: IPurchaseASeed, IPlantASeed
{
...
}

Now we see 2 interfaces in this code. If we wanted, we could write them as follows, right?

interface IPurchaseAndPlantASeed
{
bool BuySeed(Seed seed);
bool PlantASeed(Garden garden, Seed seed);
}
class Player: IPurchaseAndPlantASeed
{
...
}

But if we write it this way, we are stuck when a Player is added that should not be able to perform these operations in the future. At this point, we saw how important this principle is to support the production of scalable structures.

Dependency Inversion Principle

Dependencies between classes should be as low as possible, especially high-level classes should not depend on lower-level classes.

Normally, I did not have the opportunity to use this principle in the project I am currently developing. However, if I could add it, let me tell you how I should add it.

You may have noticed that I keep the Inventory object inside the Player class. This is the right approach because every Player has an Inventory. However, as I make improvements to the Inventory class, I can unwittingly influence the behavior of the Player class. This is something I don’t want. At this point, it would be best to write an interface where I can access the user’s inventory. Because this interface never changes. It does one job. Now let’s examine the example.

Define an interface for the Inventory functionality:

public interface IInventory
{
List<Plant> GetHarvestedPlants();
void AddHarvestedPlant(Plant plant);
void RemoveHarvestedPlant(Plant plant);
}

Modify the Inventory class to implement the IInventory interface:

public class Inventory : IInventory
{
private List<Plant> HarvestedPlants { get; set; }

public Inventory()
{
HarvestedPlants = new List<Plant>();
}

public List<Plant> GetHarvestedPlants()
{
return HarvestedPlants;
}

public void AddHarvestedPlant(Plant plant)
{
HarvestedPlants.Add(plant);
}

public void RemoveHarvestedPlant(Plant plant)
{
HarvestedPlants.Remove(plant);
}
}

Modify the Player class to depend on the IInventory interface:

public class Player
{
private int Money { get; set; }
private int LandPlots { get; set; }
public IInventory Inventory { get; set; }

// Constructor to inject the inventory
public Player(IInventory inventory)
{
Inventory = inventory;
}
}

As you can see, I have integrated this as well. In this way, the code infrastructure became even more scalable.

As a result, in this article, I showed you both how to design object-oriented programming and SOLID principles with more realistic examples. I hope this article has been useful to you and will help you develop a better software infrastructure.

Emrecan Ozkan
Software engineer

--

--