Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Deciding Between Ease and Extensibility

DZone's Guide to

Deciding Between Ease and Extensibility

Wondering which is better, doing things the right way, or getting them to work? Here's a look at ease and extensibility.

· Java Zone
Free Resource

Download Microservices for Java Developers: A hands-on introduction to frameworks and containers. Brought to you in partnership with Red Hat.

In the day-to-day tasks of development, a very common question arises: Should I spend the time to make this flexible and extensible ("do it the right way") or should I do it the quickest way possible ("get it to work")? In the former case, you will end up with a very extensible solution that makes future development easier, or you may end up spending time now to create a solution that will either not be used in the future or will become encumbering (the complexity of the solution means that you have more software complexity to maintain in the future). In the latter case, you may get by with the simplest solution and get the job done on time, or if you are smooth, ahead of schedule, but maybe you start to accrue technical debt or create a concoction that makes every other developer cringe when they get near it. 

With these two options and any degree between these two poles, we need to make a decision between ease of development (getting the job done) or extensibility (doing the right way, so that future development is easier). We should also not lure ourselves into thinking these choices are a dichotomy or are mutually exclusive: Even if we decide we need to be more extensible than swift, we are making the decision to favor one factor over another. As with most questions in software engineering, there is no single answer and no silver bullet, but that does not mean there are not points we can examine to make a better decision. These are by no means the only points to consider, but these are some points that will steer us in the correct direction (think selecting between North, South, East, or West rather than between a heading of 0 degrees or 5 degrees).

In order to drive this discussion, we will explore three different solutions to the following problem:

Create a class, Link, that represents a communication link that has an associated throughput.

First, we will start with the simplest solution and end with the most extensible solution (not in absolute terms, but of this group of solutions); using these various solutions, we will explore some points to make the decision easier.

The "Get it Done" Solution

Based on the problem statement above, we can create a very simple link class that has an associated throughput attributed. This leads to our first design choice: What units should this throughput be measured in? This is not a one-size-fits-all or arbitrary value, but for the sake of this solution, we will pick kilobits per second (Kbps). This simple class is

public class Link {

    private double throughputInKbps;

    public double getThroughputInKbps () {
        return throughputInKbps;
    }

    public void setThroughputInKbps (double throughputInKbps) {
        this.throughputInKbps = throughputInKbps;
    }
}

This is arguably as simple as it gets. The following is an example of its use:

public class Main {

    public static void main (String[] args) {
        // Create the link
        Link link = new Link();

        // Set the throughput
        link.setThroughputInKbps(110.0);

        // Print the link throughput
        System.out.println("The throughput of the link is " + link.getThroughputInKbps() + " Kbps");
    }
}

The output of this main method is no surprise:

The throughput of the link is 110.0 Kbps

The advantage of this technique is that is simple: It took about a minute to design, implement, and test this class. Within two minutes, we are onto the next task, with the confidence that this solution achieves the desired functionality. The major disadvantage becomes apparent when we wish to set the throughput in megabits per second (Mbps). What if that value is 145.76 Mbps? We can achieve this with the existing solution, but it's not pretty:

link.setThroughputInKbps(145760.0) If we step back and read that statement, it's not very apparent that we are setting the throughput to 145.76 Mbps. A keen developer might propose that we instead create a new method for setting the throughput in terms of Mbps and perform the conversion within that method:

public void setThroughputInMbps (double mbps) {
    this.throughputInKbps = mbps * 1000.0;
}

Now, we can set the link throughput as follows: link.setThroughputInMbps(145.76). That is much easier to read and the meaning of that statement is evident, even to the most novice developer. Even so, what if we wanted to print the throughput in terms of Mbps? Or, if instead, we also want to get and set the throughput in terms of bits per second (bps)? The interface and logic of this link class starts to grow rapidly as more units are introduced. This is a noticeable hindrance, especially if the throughput is only one part of the link class: The number of methods supporting a minor functionality grows, cluttering the interface of the link and relegating the most pertinent functionality to a lower importance. Some of these issues can be resolved in the next solution.

The "Happy Medium" Solution

In the simple solution, a new set of methods must be created for each of the throughput units. What if, instead, some marker is provided that denotes the units of the throughput? This would allow a single get and set method to be used, where the marker denotes the unit of the throughput desired. Based on this decision, an enumeration is a good fit. A question still remains, though: What should be used for the value of the enumeration options? A clever solution is to set the value to the conversion factor, with respect to the base units. The reason for this choice becomes evident when the implementation is viewed. For simplicity, the base units are set to bps, since this allows the conversion factors to be non-fractional numbers. The resulting enumeration is

public enum Units {

    BITS_PER_SECOND(1),
    KILOBITS_PER_SECOND(1000),
    MEGABITS_PER_SECOND(1000000);

    private int conversionFactor;

    Units (int conversionFactor) {
        this.conversionFactor = conversionFactor;
    }

    public int getConversionFactor () {
        return this.conversionFactor;
    }
}

The resulting link class is

public class Link {

    private double throughputInBps;

    public double getThroughput (Units units) {
        return throughputInBps * units.getConversionFactor();
    }

    public void setThroughput (double throughputInBps, Units units) {
        this.throughputInBps = throughputInBps / units.getConversionFactor();
    }
}

By setting the value of the enumerated options to the conversion factor with respect to the base unit (bps), the enumeration acts not only as a flag that denotes the desired units but also allows the link class to use its value as a means to convert to and from the base units. An example of its usage is seen below:

public class Main {

    public static void main (String[] args) {
        // Create the link
        Link link = new Link();

        // Set the throughput
        link.setThroughput(145.76, Units.MEGABITS_PER_SECOND);

        // Print the link throughput
        System.out.println("The throughput of the link is " + link.getThroughput(Units.MEGABITS_PER_SECOND) + " Mbps");
    }
}

This main method results in the following output:

The throughput of the link is 145.76 Mbps

The main advantage of this solution is that as new units are added, the link class does not need to change: A new value can be added to the enumeration and the link class continues to function without change. One of the disadvantages of this technique is seen in the innocuous print statement: Because we do not store state with the units enumeration (apart from the conversion factor), we have to manually write the string value of the units in the print statement. More precisely, the state of the units is separate from the units themselves, since getThroughput method of the link class returns a primitive double value (with no further state). Due to this, the selected units are actually set twice: (1) the enumeration option provided to the get method and (2) the string representing the units in the print statement. The final solution will resolve this issue by introducing units associated with state.

The "Get it Done Right" Solution

The last solution is more complex but provides the greatest extensibility. Instead of passing a value with an associated enumeration option, the throughput data is encapsulated in its own class hierarchy. The base class in the hierarchy represents the throughput in base units, and each of the subclasses simply reduces the parameters in the constructor. For example, the base class of the hierarchy can be implemented as follows:

public abstract class Throughput {

    private double value;
    private double conversionFactor;
    private String units;

    public Throughput (double value, double conversionFactor, String units) {
        this.value = value;
        this.conversionFactor = conversionFactor;
        this.units = units;
    }

    public double getValue () {
        return this.value;
    }

    public double getConversionFactor () {
        return this.conversionFactor;
    }

    public void setThroughput (Throughput other) {
        this.value = other.getValue() * (this.conversionFactor / other.getConversionFactor());
    }

    @Override
    public String toString () {
        return this.value + " " + this.units;
    }
}

The subclass implementations for Mbps, Kbps, and bps are depicted below: 1

public class Mbps extends Throughput {

    public Mbps (double value) {
        super(value, 1000000.0, "Mbps");
    }
}
public class Kbps extends Throughput {

    public Kbps (double value) {
        super(value, 1000.0, "Kbps");
    }
}
public class Bps extends Throughput {

    public Bps (double value) {
        super(value, 1.0, "bps");
    }
}

The link class is then reduced to the following:

public class Link {

    private Throughput throughput;

    public Throughput getThroughput () {
        return throughput;
    }

    public void setThroughput (Throughput throughput) {
        this.throughput = throughput;
    }
}

Since the units string is stored as state in each of the classes in the hierarchy, we are not required to print the units manually. Instead, we simply delegate to the returned object to obtain the formatted string for the throughput value:

public class Main {

    public static void main (String[] args) {
        // Create the link
        Link link = new Link();

        // Set the throughput
        link.setThroughput(new Mbps(145.76));

        // Print the link throughput
        System.out.println("The throughput of the link is " + link.getThroughput().toString());
    }
}

Note that we do not have to explicitly call the toString method of the returned throughput object, but doing so explicitly shows that we are delegating to the functionality of the throughput object to obtain the units string. The main advantage of this solution is that it opens up the flexibility and extensibility of classes to the throughput value. For instance, if we wanted to add a new unit for the throughput, say Gigabits per seconds (Gbps), we simply create a new class in the hierarchy. This means that the link class can remain unchanged, just as in the second solution.

While storing the conversion factor and the string units are a simple example of how a class hierarchy can be used to create a very extensible solution, more complex solutions can also be devised. For example, if we were to create a full-fledged library for rates (throughput or otherwise), we could conceive of a solution that results in the following main method:

public class Main {

    public static void main (String[] args) {
        // Create the link
        Link link = new Link();

        // Set the throughput
        link.setThroughput(new Kilo(new Bits(145.76)).per(new Seconds()));

        // Print the link throughput
        System.out.println("The throughput of the link is " + link.getThroughput().toString());
    }
}

While this last example shows the level of extensibility that can be achieved using a class hierarchy, it is not a perfect solution. In fact, it is a double-edged sword: Although this last example depicts the use of a very easy-to-read and flexible framework, it may require a great deal of work to design, implement, and test. There are trade-offs and the costs and benefits depend on the situation and the problem context, which will be explored in the following section.

1Note that the Bsp class has a conversion factor of 1.0; even though the Throughput class can be used to represent this base unit, a Bsp class is created to explicitly denote that bsp is being used, since the base units are unknown to the client using this class hierarchy.

Making the Correct Decision

There is no free lunch: It is very difficult to obtain the usefulness of the third solution with the lack of development expense of the first solution. So, what can we do? Based on the advantages and disadvantages of the above solutions, we can think about a few key points before making the decision to do it quick or lace our boots up and go in knee-deep:

  1. You will eventually be wrong. This is a simple truism, but for a lot of us, it hurts. Why? Because no one likes to be wrong, but the real world of software engineering is not perfect and neither are we. We are humans and make mistakes. The quicker we accept this lesson the better. What does that mean for our decision making? There is no perfect answer and sometimes, you will choose the wrong answer and pay the price for it later. Because of this, we should consider the following:

    1. What is the price I will pay if I am wrong? If the price we pay for our mistake is cheap, then we should not spend too much time thinking about it. For example, if it will cost us 2 hours to be wrong, we should not spend an entire day debating the decision with our team. On the other hand, if it will be painful to change our decision at a later point, it is worth it to spend the time now to get it right (as best as we know in the current environment).

    2. Am I making this decision on a whim or am I really being honest with myself about which solution is best? We all have biases, and it can be difficult to discover our own. For example, most software engineers I have spoken to (as well as myself) have an inherent desire to create the solution with the highest coolness or cleverness factor. This usually means we desire the most complete or comprehensive solution, but that solution is not always feasible for the project schedule we are up against. Remember that time is not infinite and it is not free: Time spent on a project is a limited resource and it costs money.

  2. Make an educated guess on the cost now and on the cost in the future. Cost estimation (in terms of both time and money) is an inevitable part of any decision making on a software project. Unfortunately, it usually takes time and experience to make good estimations. Regardless of your level of experience, make the best estimation you can about how long it will take you create the solution and how much it will cost you in the future. The future cost is divided into two parts: (1) the cost that maintaining the chosen solution will incur, and (2) the cost of not implementing a better solution. The first cost can be thought of as the going forward cost: Someone somewhere will have to maintain the code created for the solution. If the solution is overly complex, this cost may outweigh the benefits of the most extensible solution. The second cost can be thought of as the what could have been cost: It is the increased effort of development in the future that could have been reduced if a better solution had been created now.

    In many cases, we will not be able to put an exact number to the costs and saved time, but using your gut will be a very important part of making that decision. Just remember in which direction of the factors pull: If you feel in your gut that the negative factors pull much harder than the positive factors, it's probably not worth it to choose that solution.
  3. Tend towards the simplest solution. As has become a maxim in the age of Agile software development: You Ain't Going to Need It (YAGNI). Many times, we create great solutions that are complex but we never use them: The needs of the customer changes, the tools we use change, the design changes, and any number of other factors change that affects the needs of our project down the road. A lot of these changes are difficult to foresee and account for in the present, which means we should tend to only do as much work as is necessary to get the product complete in the present. In the context of this article, this does not mean we should automatically use the first solution (the simplest solution), but rather, regardless of the solution selected, only enough effort should be expended to get it working. For example, if the third solution is selected, only the needed classes should be created. Do not create a complete list of all throughput classes for the sake of completeness. Instead, if only Mbps, Kbps, and bps are needed, create and test those classes now. If, in the future, the need arises for Gbps, create that new class when the need arises. Creating the Gbps class now means more testing and maintenance overhead now, and in the end, the Gbps class may never be used.
  4. Understand your project type and its goals. There are different types of projects, and each has its different set of needs. If you are releasing your application to an external customer, and only other employees within your team will use your code, gauge their opinion and see if the added complexity is worth it. A simple, "Would you use this if I built it?" can go a long way. If, instead, you are creating a framework or library that will be used by an unknown set of clients, being highly extensible may be a benefit. For example, the third solution (using a complete throughput or rate framework) may be the most appropriate, since it allows a client to provide the throughput using any value he or she desires (similar to the way testing and mocking frameworks provide numerous methods and classes for different combinations of test structures to be created). Keep in mind, though, even when creating libraries or frameworks, there is usually a way to gauge your users and decide on appropriate functionality. For example, if there are numerous bug reports or feature requests for a more extensible throughput module, it may be worth it to invest the time in developing one.
  5. You can go back and refactor. Remember: You are not stuck with a decision. If you feel as though, in the current situation, the simplest solution is best, select it. When you reach the point where it becomes too cumbersome to maintain that simple solution, you can refactor it into a more complex solution. For example, if we chose the first solution, and later we need to add the ability to get and set the throughput using Mbps, we can easily add those methods when needed. If later, we need 7 different units for throughput, it may be worth it to refactor our design and use a more extensible solution, like the second or third solution. Just because it was a good choice to use the simplest solution at one point in the project does not mean that assumption still holds true today. If that happens, change the design to meet your current needs. Keep in mind, though, sometimes going back and refactoring can be painful: What if this code is used in numerous places by an external client outside your control? This is why is important to consider the cost if you are wrong in your decision. If your choice will affect only one or two places in the code you control, the cost of being wrong is low; if instead, as above, the decision will affect code outside your control, the cost of being wrong may be much higher.

All of the above points are obvious points, but many times, we do not take the time to consider them. We make a decision that may end up costing days of time based on a simple 10-second stream of thought. Instead, take a minute and think about the trade-offs. Talk to the other developers on your team and see what they think. You never know, what you thought may be a great addition to your system may be met with a, "We would never use that." Or, vice-versa: Something you considered to be worthless may be met with a, "We've been waiting for something like that for weeks!" When you reach a crossroads, take a minute and think about it: The more you do it, the better you become at it.

Download Modern Java EE Design Patterns: Building Scalable Architecture for Sustainable Enterprise Development.  Brought to you in partnership with Red Hat

Topics:
java ,technical debt ,design decision ,extensibility ,flexible ,decision making

Opinions expressed by DZone contributors are their own.

THE DZONE NEWSLETTER

Dev Resources & Solutions Straight to Your Inbox

Thanks for subscribing!

Awesome! Check your inbox to verify your email so you can start receiving the latest in tech news and resources.

X

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}