DEV Community

Cover image for Designing Public Interfaces the Right Way
Erik
Erik

Posted on • Originally published at erikscode.space

Designing Public Interfaces the Right Way

Originally Posted at eriksCodeSpace

Interfaces define the ways objects interact with each other. Properly designing interfaces not only help conceptualize a system, but aid in testing and maintainability of our systems.

What Are Interfaces?

This article is going to go beyond the concept of just using the principle of least privilege. We’re learning about this in the context of good design so you can write maintainable and testable code. The examples in this article are going to be in C# because I believe the syntax is optimal for explaining these concepts, but the concepts themselves transcend languages and apply to all OOP designs.

For now, put aside the idea of interface as a programming language construct; right now we’re going to use it in its regular sense. An “interface” is the point in which two entities meet and interact. For example, your kitchen sink’s interface is the hot and cold handles plus the faucet.

There are two entities: the sink and you. The sink provides a lot of functionality, but most of it is hidden from you. For example, you don’t need to know how the sink magically summons the water it spits out of the faucet; you don’t need to know how the sink is heating that water; and you definitely don’t need to know how the sink drains the water after it’s washed over your hands and dishes.

All you need to know as a user of the sink is how to turn the water on and off, and which dial gives you what kind of water. You, the calling entity, don’t know the inner workings of the sink, the subject entity. To put this back into a programming point of view, the sink interface’s functional signature would take in the hot and cold dials as arguments and return water.

Imagine if you had to know everything about plumbing just to wash your hands. Not only is this unrealistic, but it makes changing things nearly impossible. If the city implemented a new draining system, you’d have to relearn how that works before you can soak your dishes.

Likewise, this is exactly what happens when you have objects that are too tightly coupled. The calling object that knows too much about its subject is like the plumber washing their hands. The public interface of an object needs to be simple, and needs to change as infrequently as possible.

That last sentence is basically the main concept behind designing good interfaces: public interfaces should rarely, if ever, change.

Let’s Do an Example

Like I said at the beginning of this article, our examples will use the C# programming language. I think the syntax for objects and their interfaces is the best one to illustrate the necessary examples.

We’re going to make a very simple command line RPG style fighting simulator. We’re going to have a Hero that attacks different types of Enemies. Let’s start by writing an abstract Enemy class:

namespace PubPriExample2
{
    public abstract class Enemy
    {
        public string name { get; set; }
        public int hitPoints { get; set; }
        public int strength { get; set; }
        public int defense { get; set; }
        public bool alive { get; set; }

        public Enemy(string enemyName, int hp, int str, int def)
        {
            name = enemyName;
            hitPoints = hp;
            strength = str;
            defense = def;
            alive = true;
        }

        public abstract void takeDamage(int attackDamage);
        public abstract bool isAlive();
        public abstract void die();
    }
}
Enter fullscreen mode Exit fullscreen mode

What do we have here? This abstract class defines the base class for all enemies. Each enemy will have a name, hit points that represent how much damage it can take, strength which will control its attack damage, defense which controls how it takes damage, and a boolean variable called alive that will be set to false when the hit points fall below zero.

We also have a few methods. Besides the constructor, we have takeDamage, isAlive, and die. These are methods that every enemy must have because it is the only thing we’ll do with enemies in this game. All enemies will be attacked, will be alive or dead, and do something when they die.

Let’s make our Hero class. Most of this class will look like it should be extending the Enemy class, but we’re not going to do that. If this were to evolve as a full on RPG, Hero would probably also evolve into an abstract class used by multiple customizable heroes. For now, this class will just help us interact with the Enemy classes.

using System;

namespace PubPriExample2
{
    class Hero
    {
        public string name { get; set; }
        public int hitPoints { get; set; }
        public int strength { get; set; }
        public int defense { get; set; }

        public Hero(string heroName, int hp, int str, int def)
        {
            name = heroName;
            hitPoints = hp;
            strength = str;
            defense = def;
        }

        public void attack(Enemy enemy)
        {
            Console.WriteLine(name + " attacks " + enemy.name);
            enemy.takeDamage(strength);
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

We haven’t made any takeDamage type methods for this class because to illustrate the points I’m trying to make, they are not necessary. The one thing I do want you to note here is that is that attack takes in an Enemy object, and uses the enemy’s takeDamage method to interact with it. Hence, the takeDamage method is Hero‘s interface to Enemy.

Let’s make a couple of concrete enemy classes. First, we’ll make RegularEnemy:

using System;

namespace PubPriExample2
{
    class RegularEnemy : Enemy
    {
        public RegularEnemy(string enemyName, int hp, int str, int def) : base(enemyName, hp, str, def)
        {
        }

        public override void takeDamage(int heroStrength)
        {
            int damage = calculateDamage(heroStrength);
            hitPoints -= damage;
            Console.WriteLine(name + " takes " + damage + " points of damage");
            if (isAlive()) die();
        }

        public override bool isAlive() => hitPoints > 0;

        public override void die()
        {
            alive = false;
            Console.WriteLine(name + " has died!");
        }

        private int calculateDamage(int attackStrength)
        {
            int damage = attackStrength - defense;
            if (damage > 0)
            {
                return damage;
            }
            else
            {
                return 0;
            }
        }

    }
}

Enter fullscreen mode Exit fullscreen mode

NOTE: If the => syntax is throwing you off, don’t worry, it’s just some C# syntactic sugar. public override bool isAlive() => hitPoints > 0; is exactly the same as

public override bool isAlive()
{
    return hitPoints > 0;
}
Enter fullscreen mode Exit fullscreen mode

Here, we’ve overridden the base class’s abstract methods takeDamage, isAlive, and die. The one we’re going to pay attention to is the takeDamage method because, remember, it is the public interface at this moment.

Why, if we have takeDamage, do we also have calculateDamage? Why can’t we put the logic for calculatign the damage in the takeDamage method? We could, but this is part of what we’re learning.

First of all, calculating the damage within the same method in which we apply the damage would violate the Single Responsibility Principle of SOLID Object Oriented Design. We want each method to have a single responsibility, and the takeDamage method’s responsibility is applying damage, not calculating it.

Secondly, takeDamage is the interface that the Hero class is going to use to interact with Enemy, so we want it to be relatively uniform across all Enemy sub types. We will make other enemies that can also be attacked, but will take damage in different ways. calculateDamage is therefore the private method that will do the sub-type-specific operations to figure out how much our hero has damaged the enemy.

Speaking of, let’s make our second Enemy class, the ArmoredEnemy:

using System;

namespace PubPriExample2
{
    class ArmoredEnemy : Enemy
    {
        public ArmoredEnemy(string enemyName, int hp, int str, int def) : base(enemyName, hp, str, def)
        {
        }

        public override void takeDamage(int heroStrength)
        {
            int damage = calculateDamage(heroStrength);
            hitPoints -= damage;
            Console.WriteLine(name + " takes " + damage + " points of damage");
            if (!isAlive()) die();
        }

        public override bool isAlive() => hitPoints < 0;

        public override void die()
        {
            alive = false;
            Console.WriteLine(name + " has died; the armor shatters");
        }

        private int calculateDamage(int attackStrength)
        {
            // Enemy has 20% change of deflecting the attack
            Random random = new Random();
            int deflect = random.Next(1, 6);
            if (deflect == 1)
            {
                deflectAttack();
                return 0;
            }
            else if ((attackStrength - defense) > 0)
            {
                return (attackStrength - defense);
            }
            else
            {
                return 0;
            }
        }

        private void deflectAttack()
        {
            Console.WriteLine(name + " deflects the attack!");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice here that the ArmoredEnemy takes damage a different way. The enemy’s armor may deflect our hero’s attack, and thus the calculateDamage method essentially rolls the dice to find out if the attack was deflected or not.

Now, let’s see some actual use of these methods:

using System;

namespace PubPriExample2
{
    class Program
    {
        static void Main(string[] args)
        {
            Hero hero = new Hero("Erik", 100, 10, 5);
            RegularEnemy imp = new RegularEnemy("Imp", 50, 3, 2);
            ArmoredEnemy armorImp = new ArmoredEnemy("Armor Imp", 30, 4, 4);

            while (imp.alive)
            {
                hero.attack(imp);
            }

            while (armorImp.alive)
            {
                hero.attack(armorImp);
            }

            Console.ReadLine();
        }

    }
}
Enter fullscreen mode Exit fullscreen mode

Run this and you should get something similar to the following output:

Erik attacks Imp
Imp takes 8 points of damage
Erik attacks Imp
Imp takes 8 points of damage
Erik attacks Imp
…truncated
Imp takes 8 points of damage
Imp has died!
Erik attacks Armor Imp
Armor Imp deflects the attack!
Armor Imp takes 0 points of damage
… truncated

Erik attacks Armor Imp
Armor Imp deflects the attack! Armor Imp takes 0 points of damage Erik attacks Armor Imp Armor Imp takes 6 points of damage …truncated
Erik attacks Armor Imp
Armor Imp takes 6 points of damage
Armor Imp has died; the armor shatters

Now consider we wanted to add even a third type of enemy, one that counterattacks. The code would mostly look the same, the new enemy would still have a takeDamage method, but there would also be a private method for implementing the counterattack. The interface for the enemy wouldn’t be any different, the hero can still only attack, but the implementation of that interface would be different, which would mean it should be hidden inside a private class of the new sub type.

When Things Change

What if we wanted to add deflection capabilities to our RegularEnemy class? Just because an enemy isn’t armored doesn’t mean it can’t deflect an attack, right? Right. So we want to add deflection capabilities to RegularEnemy but we want these enemies to have a 5% chance of deflecting rather than 20%.

In theory, this new feature should not require any changes to our public interfaces. In fact, it doesn’t:

private int calculateDamage(int attackStrength)
        {
            // Enemy has 5% change of deflecting the attack
            Random random = new Random();
            int deflect = random.Next(1, 21);
            if (deflect == 1)
            {
                deflectAttack();
                return 0;
            }
            else if ((attackStrength - defense) > 0)
            {
                return (attackStrength - defense);
            }
            else
            {
                return 0;
            }
        }

        private void deflectAttack()
        {
            Console.WriteLine(name + " deflects the attack!");
        }
Enter fullscreen mode Exit fullscreen mode

Nothing needed to change in order for this new feature to be added because we had a good separation of our public and private methods.

Interfaces Representing Roles

It’s kind of funny to have spent all this time talking about interfaces and not once using the keyword interface huh? Let’s go ahead and do that, but let’s think it through first.

What is it about Enemy types that unify them? They all have stats and a name. They can also all be attacked, but in the context of a video game is that really a common trait? For example, consider a game like Zelda, where our hero Link can not only attack bad guys with his sword, but also grass and bushes. The grass and bushes don’t need stats like hit points and strength, but they’re still attackable.

Attackable. That’s definitely not a real word, but it segues well into deciding when to use an abstract class vs when to use interfaces. Enemies are attackable. In Zelda, bushes are also attackable. So if we wanted to implement something similar in our game, like a tree our Hero could chop down with a sword, do we want the tree to extend the Enemy abstract class?

Conceptually, no. We could, of course do it this way, but making it work would look super shady. Likewise, any functionality we’d want to add to our sentient enemies would also get added to our non-sentient trees. In this case, trees and enemies don’t exactly share traits, but they do share roles. Specifically, they share the “attackable” role, which is what we’re going to name our interface. We’re going to name our interface IAttackable because that’s how you name interfaces in C#, sorry 🤷

namespace PubPriExample2
{
    interface IAttackable
    {
        void takeDamage(int attackDamage);
        bool isAlive();
        void die();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, head over to the Enemy abstract class to implement this interface:

namespace PubPriExample2
{
    public abstract class Enemy : IAttackable
    {
    // Truncated
Enter fullscreen mode Exit fullscreen mode

What has this done for us? Haven’t we really just added some constraints on ourselves, forcing us to always write these three methods for any class implementing this interface?

Well, yes. And trust me, I get it. It does look like a bit of unnecessary self-restraint, but try to look at it from a bigger picture. Imagine you’re developing this game and you decided you wanted to add attackable trees a few months after you finished the last Enemy sub type. Will you remember which methods you’ve been using for these objects? In this case, probably yes because it’s such a small project right now, but imagine the project grows to a couple thousand lines of code.

Even better, imagine you are in a large company with multiple teams working on the game. Another team has been tasked with implementing trees that our Hero can cut down. Luckily, they already have this interface and know exactly how similar functionality has been added to the game before. It lets everything that is attackable in the game have the same interface.

Finally, it gives us a little something we might neglect. It helps us understand the system better. Think about RegularEnemy and ArmoredEnemy and how they both extend the Enemy class. Any time we look at a class, regardless of its name, if we see that it extends Enemy, we know that this is something the player will fight with.

Likewise, for any class implementing the Attackable interface, we will know it takes damage from the Hero. This example might make this benefit seem inconsequential, but in large systems with business processes less easy to conceptualize, this benefit can go a long way. The programmer’s main job is managing complexity, and things like proper naming and good interface design help us do that.

A Note on Testability

We’ve learned that public interfaces should rarely change, and private ones are the real workhorses of classes. You might think then that the private methods of a class are the ones that need to be tested the most, but the opposite is true.

Private methods cannot be implemented by other classes, and the nuggets of functionality they provide are really just in support of their public methods. Therefore, thoroughly testing a class’s public interface essentially tests its private methods as well.

Similarly, as we’ve already learned, public interfaces should not change much. That means that testing of them should also not change very frequently, except when new functionality is added that changes the object’s behavior. For example, our ArmoredEnemy class implemented two private methods when taking damage. Testing the public takeDamage tests both methods satisfactorily. If we added even a third private method to the takeDamage method, we may need to change a few expected values here and there in our tests, but ultimately, test maintenance should be very low.

So not only does the proper design of interfaces help us conceptualize our systems better, it also helps cut down on maintenance costs while our tests still give us the same level of confidence they always have.

Summary

Today we learned a little bit about good object oriented design. We learned that public interfaces, not necessarily interface methods, are designed to be stable. Keeping these methods constant and hiding complexity in private methods gives us the ability to add or modify functionality almost at a whim. These concepts give us simple public interfaces and help our test suite stay maintainable and effective.

For further reading, I strongly suggest checking out Sandi Metz’s book, Practical Object-Oriented Design: An Agile Primer Using Ruby. She does a much better job at explaining this concept in chapter 4. The rest of the book is excellent as well. No this isn’t an affiliate link, I really think this book is worth reading if you’re interested in good software design.

Top comments (0)