DEV Community

Cover image for Software design: its importance and tips for creating good designs.
Raphael Silva
Raphael Silva

Posted on • Edited on

Software design: its importance and tips for creating good designs.

Software design is the way we organize the elements that make up the code, the separation, the dependencies, and how these elements communicate with each other.

Careful attention to software design is essential to enable application evolution with the least cost and effort possible. In addition to making development more enjoyable for everyone on the team and enabling the constant delivery of value, it accommodates changes in a natural way.

A good design minimizes maintenance costs because its elements are clear and easily understandable, which impacts the ability to adapt to the inevitable and unexpected changes that occur during the lifecycle of a system.

Investing in good design is extremely important, and all developers need to take on this responsibility. Therefore, in this article, I will provide some tips for creating good designs.

The purpose of this article is to introduce the concepts, principles, patterns, and practices that are essential for creating good designs. I believe that each topic covered deserves a series of articles.

Accidental complexity x Inherited complexity

One of the worst things in programming is complexity. It disrupts the team's workflow and compromises the quality of the product. To make matters worse, as programmers, we have a special knack for creating complex code and solutions. Making things simple is not an easy task, so it's worth starting by discussing the relationship between software design and complexity.

We can consider two types of complexity: accidental and inherent. Accidental complexity is directly related to the solution we create for a specific problem. This type of complexity often occurs when we propose a solution that not only solves the current problem but also takes into account possible scenarios that may never happen, or when we create confusing code that doesn't reveal its intention. The code works, but it's difficult to identify what it does and why it performs a certain action. Therefore, we should avoid this type of complexity as it significantly increases the cost of maintaining the software.

On the other hand, there is inherent complexity, which is related to the problem to be solved. Sometimes, the problem itself is complex and naturally requires a complex solution. We cannot avoid this type of complexity, but we should think of ways to minimize its impact.

Knowing the difference between these two types of complexity is a good starting point for creating good designs and, consequently, good products.

Computers are capable of "understanding" any code we write, whether it is complex or not, but it is people who modify and are responsible for evolving the software. Therefore, by caring about the complexity of the solutions we create, we are directly contributing to the ability to evolve the products we develop and to the quality of life of our colleagues.

A good design eliminates accidental complexity and hides inherent complexity.

Simple Design

More than just a practice, Simple Design is a programming style that leads us to think and create simple solutions that meet current requirements without speculating about possible future changes. Instead of trying to predict how the software will change, we focus on what it needs to do today. We invest time and effort in creating code that reflects the requirements as clearly as possible, using practices that ensure its proper functioning. We see each change as an opportunity for learning and design evolution, preparing the software to accommodate these changes naturally. We rely on design principles that help us create code that meets current requirements and is also ready to evolve easily.

This programming style distances us from accidental complexity and encourages us to think of solutions that hide inherent complexity, resulting in software that delivers the expected value and can be easily evolved. Moreover, these practices have an extremely positive psychological effect on programmers. We stop worrying about non-existent problems and focus on what needs to be done today. Anxiety decreases, allowing us to give our best to the things that truly matter.

Clean Code

Clean Code represents the details of the code. It's about being aware that details matter and that code is the only valid representation of the software. We are communicators, and code is our primary communication tool. Therefore, we should care about the expressiveness, clarity, and intention of the code we create. We should strive to write code that is easy to read and not only functions correctly but also reveals its purpose in a simple and straightforward manner.

Design Principles

Design principles are the result of experiences lived by programmers who noticed common characteristics among software that evolved more easily. These characteristics were transformed into principles that help us in constructing software that meets current needs and is ready to evolve.

We constantly have to make decisions that directly impact the design of the code. By understanding design principles, we can make informed decisions.

In addition to enabling the creation of good designs, mastering these principles is crucial in the process of learning and using design patterns. Essentially, patterns are based on principles and provide a proven and documented way to apply them to solve a problem while ensuring the quality of the code design. Therefore, mastering design principles facilitates the learning and application of patterns, making us capable of devising patterns to solve specific problems in our software.

Design Patterns

Design patterns are documented and widely adopted solutions for solving problems in code development. Their purpose is to create flexible and expressive code. Understanding patterns, their intentions, and, most importantly, mastering the principles and values they are based on significantly elevates our ability to create good code.

Furthermore, due to their widespread usage and documentation, applying patterns facilitates communication. Each pattern carries a wealth of information, and simply mentioning that a particular pattern was used allows others to envision the implementation details.

On the other hand, misuse of patterns can increase application complexity. A common scenario is trying to use a pattern to solve an inappropriate problem, similar to using a screwdriver to hammer a nail. Patterns were not created to be used in such a manner. Hence, it is crucial to understand patterns, knowing when to use them and, more importantly, when not to use them.

A good time to employ patterns is during refactoring. This is when we focus on improving the code design, which may already exhibit some issues. Thus, we can identify whether applying a pattern is appropriate to address the existing problems.

We should know and master design patterns, understanding their intentions, and use refactoring to identify problems that can be resolved through the application of a pattern. It is important to remember that patterns were created to solve specific problems. Using them to solve problems they were not designed for can harm the application, increasing its complexity and reducing its ability to respond to changes.

TDD

Test Driven Development (TDD) is an excellent tool for code development, and I would like to delve into some of its details and explain why I consider this practice an instrument for creating good code.

Most high-quality code that I have come across was written by individuals who divide the code-writing process into two phases. The first phase focuses on solving the problem, with little to no effort invested in refining the design. The second phase is dedicated to design refinement. This is because trying to create good design, think about the problem solution, and simultaneously build the code that materializes those thoughts is not always a trivial task. Therefore, it is a good strategy to focus on problem-solving first and then spend time refining the code's design. However, for this strategy to work, it is essential to ensure that changes made to improve the code's design do not affect its behavior. Additionally, this strategy tends to yield better results when there is frequent alternation between these phases during the code's evolution.

We can use TDD to assist us in code writing, enabling us to focus on the problem to be solved and the design in separate phases through small iterative cycles that provide feedback. This feedback can be utilized to gradually improve the code, all with the guarantee that design improvements will not alter the application's behavior, as long as we have the discipline to follow the TDD rules.

The 3 rules of TDD are as follows:

  1. Do not write any production code unless there is a failing test.
  2. Write only enough test code to fail.
  3. Write the minimum amount of production code necessary to pass the test.

The purpose of these 3 rules is to ensure that all written production code is adequately tested because each piece of production code is written to pass a test representing a specific scenario.

In addition to these rules, TDD consists of the following stages that guide us in correctly applying the technique:

  1. RED: In this stage, we focus on the scenario we want to implement by creating a test that represents this scenario. The test should fail until the scenario is implemented. It is crucial to execute the test even though it is expected to fail and ensure that the reason for the failure is because the scenario has not yet been implemented.

  2. GREEN: In this stage, we focus on making the test from the previous stage pass. In other words, we concentrate on implementing the scenario without much concern for the code's design. It is important to implement only the minimum necessary for the test to pass, thus avoiding creating code that is not covered by tests.

  3. REFACTORING: In this stage, we focus on the design of the code we created. We should analyze it and identify areas for improvement. This is the ideal moment to identify code smells and apply refactoring techniques, applying design principles and patterns to enhance the quality of the code we created.

These stages constitute an iterative cycle that repeats until the development is complete. This cycle provides constant feedback on the code's quality and enables us to take small, continuous steps toward the final goal. These small steps provide a sustainable rhythm and keep us in control of the code throughout the development process.

Refactoring

Refactoring is the practice of changing the code's design without modifying the application's behavior. Through this practice, we can build software that not only meets the current needs but is also ready to evolve.

Programming is an art, and like any other art, it requires a series of refinements until the result is truly satisfactory. We don't need to carry the burden of creating perfect code right from the start. Instead, we should commit to making these refinements to improve the code's design and prepare our applications to accommodate changes.

Unfortunately, some people underutilize this practice. Perhaps because they cannot quickly ensure that refactoring will not impact the software's functionality, they give up on the idea of refactoring. After all, for them, functioning software is better than nothing. However, they may not realize that by doing so, they can negatively influence the software's ability to evolve. Changes that were once simple to make start to become more complex and consequently more costly.

Knowing the importance of this practice, we need to have peace of mind and confidence when refactoring code. To achieve this, it is crucial to have automated tests that can be run constantly, helping to ensure that changes do not impact the application's behavior. The use of TDD allows us to refactor the code multiple times during its development. As a result, in the end, we have working code, reliable test coverage, and a design that enables the software to continue evolving with the lowest possible cost. Our team members will appreciate this care for the code, including ourselves in the future.

Pair Programming and Code Review

We are good at finding issues in other people's code. Knowing this, we can leverage this skill to contribute to others' code and allow them to contribute to ours.

There are two practices that are helpful for facilitating this exchange of feedback: Pair Programming and Code Review. One of the purposes of these practices is to have code built by more than one person.

Code Review is a practice that typically occurs after completing the implementation. The code goes through a review stage where others evaluate its functionality, implementation impacts, and, most importantly, the quality of the design. It is a moment to assess the simplicity and intention of the code, as if the reviewers have difficulty understanding the implementation, it is likely that the code can be improved. When this happens, the reviewer should provide feedback to the code creator and help them improve the design.

Although it is often used after implementation, I believe that Code Review can be better utilized during Pair Programming. Pair Programming is the practice where two people work together on the implementation, sharing the same keyboard. One person is responsible for writing the code while the other assists with research, decision-making, and, most importantly, code review. The great advantage is that during pairing, code review occurs continuously throughout the code-writing process, resulting in a shorter feedback cycle compared to code review conducted after implementation. This prevents the review from becoming a hindrance in the development flow.

It may seem controversial that the use of Pair Programming can be effective. In theory, it would be more productive if each person worked on a separate task, and this would be true if our most challenging task were writing code. However, we know that's not the case. During software development, there are many factors that influence its success, such as the expected functionality, performance, and adaptability. The software needs to fulfill its intended purpose satisfactorily and be ready to evolve. With this in mind, when we work together towards a common goal, we significantly increase our chances of creating a product that possesses all the desired characteristics because two heads think better than one.

We can use these practices as an opportunity to exchange feedback that will aid in constructing code that is easy to maintain while also sharing knowledge among team members.

Conclusion

People expect us to develop software that meets their current needs but is also ready to evolve. Therefore, it is our responsibility to plan the design of our applications. To do so, it is essential to master the concepts, principles, patterns, and practices that assist us in building good designs. Lastly, we must adopt a professional mindset and have the discipline not to compromise the quality of the code we write. Our decisions should contribute to the quality of the software design, as this is the only way to build a product that satisfies customers, adapts to changes, and remains profitable throughout its lifecycle.

References:

  • Extreme Programming Explained: Embrace Change, Kent Beck
  • Clean Code, Uncle Bob
  • TDD By Example, Kent Beck
  • Refactoring, Improving the design of existing code, Martin Fowler
  • Head First Design Patterns, Eric Freeman and Elisabeth Robson
  • Practical Object-Oriented Design: An Agile Primer Using Ruby, Sandi Metz

Top comments (0)