Learn Computing from the Experts | The Rheinwerk Computing Blog

Techniques for a Good Software Architecture Design

Written by Rheinwerk Computing | Jul 25, 2024 1:00:00 PM

There are specific techniques for achieving a good software architecture design that a software architect should know.

 

An important challenge in the design of software architectures is effective management of the interdependencies of the individual software building blocks. Sometimes, dependencies can’t be avoided and are even advantageous—for example, if a message has to be sent to another class or a specific method from a different subsystem has to be called.

 

What’s important is that you always discuss designs and keep your alternatives and options open. Models aren’t reality and should always be coordinated with the end user and the client.

 

Initial Situation and Motivation: Degenerated Design

With software that is frequently modified over long periods, the structure of the software can degenerate in the course of time. This is a general problem. In the beginning, architects and designers create a clean, flexible software structure that can still be seen in the first version of the software. However, after the initial implementation, changes in requirements are usually unavoidable, meaning that the software has to be modified, extended, and maintained. If the initial design isn’t taken into account during this process, the original structure can become unrecognizable and extremely difficult to understand.

 

Three basic symptoms indicate a degenerated design:

  • Fragility: Changes in one place can result in unforeseen errors in other places.
  • Rigidity: Even simple modifications are difficult and affect a large number of dependent components.
  • Low reusability: Components can’t be individually reused due to their many dependencies.

 

Loose coupling

As already explained, the relationships between the building blocks and components enable effective collaboration and thus form part of the basis of the entire system. Relationships, however, lead to dependencies between components, which in turn can lead to problems. For example, a change to an interface means that all building blocks that use this interface may have to be changed too. This relationship between building blocks, along with its strength and the resulting dependency, is referred to as coupling.

 

A simple measure of how strongly a component is coupled to other components is to count the relationships between them. In addition to quantifying it, the nature of a coupling is important too. Some examples of types of coupling are as follows:

  • Call: A coupling exists when a class directly uses another class by calling a method of that class.
  • Generation: A different type of coupling exists when a building block generates another building block.
  • Data: A looser coupling exists when classes communicate via a global data structure or solely via method parameters.
  • Execution location: A hardware-based coupling exists when building blocks have to run in the same runtime environment or on the same VM.
  • Time: A temporal coupling exists when the chronological sequence of making calls to building blocks impacts the end result.
  • Inheritance: In object-oriented code, a subclass is already coupled to its parent class due to the inheritance of attributes. The level of coupling depends on the number of inherited attributes.

 The aim of loose coupling is to reduce the complexity of structures. The looser the coupling between multiple building blocks, the easier it is to understand an individual building block without having to inspect a lot of other building blocks. A further aspect is the ease of modification: the looser the coupling, the easier it is to make local changes to individual building blocks without impacting other building blocks. An example of loose coupling is the observer pattern.

 

 

The only thing that the subject knows about its observers is that they implement the observer interface. There is no fixed link between observers and a subject, and observers can be registered or removed at any time. Changes to the subject or the observer have no effect on the other party, and both can be reused independently of one another.

 

High Cohesion

The term “cohesion” comes from the Latin word cohaerere, which means “to be related.” The principle of loose coupling often leads to the principle of high cohesion, as loose couplings often result in more cohesively designed building blocks.

 

A cohesive class solves a single problem and has a specific number of highly cohesive functions. The greater the cohesion, the more cohesive the responsibility of a class in the application.

 

Here, too, it’s a matter of how easily system building blocks can be locally modified and understood. If a system building block combines all the properties necessary for understanding and changing it, you can alter it more easily without involving other system building blocks.

 

You should not group all classes of the same type in packages (e.g., all filters or all entities), but instead group by systems and subsystems. Cohesive packages accommodate classes of a cohesive functional complex.

 

Single Responsibility Principle (SRP)

The single responsibility principle (SRP) states that a class should not have more than one reason to change it. The principle was defined by Robert C. Martin in 2005 with dependency management in mind. If a class has many responsibilities, it will be used in many places. If the code is changed, a lot of effort must be made to ensure continued correct cooperation with users. Furthermore, code with many responsibilities is more extensive. If the SRP is followed, implementing a change request requires less effort.

 

The formulation of the principle allows for several interpretations that aren’t consistent with the principle: in particular, that a class should only have one public method or can only take on one task.

 

It’s possible to generalize the SRP by dropping the reference to the class level. A generalization comes from Ralf Westphal:

 

A functional unit on a given level of abstraction should only be responsible for a single aspect of a system’s requirements. An aspect of requirements is a trait or property of requirements, which can change independently of other aspects.

 

The Open/Closed Principle

The open/closed principle was defined in 1988 by Bertrand Meyer and states that software modules should be open for extension but closed for modification.

 

“Closed” in this context means the module can be used without risk because its interface no longer changes. “Open” means the module can be extended without problems.

 

In short: A module should be open for extensions.

 

The original functionality of the module can be adapted by means of extension modules, whereby the extension modules handle only the deviations between the desired and the original functionality.

 

A module should be closed for modifications.

 

To extend the module, no changes to the original module are necessary. It should therefore provide defined extension points to which extension modules can be connected.

 

The solution of this apparent contradiction lies in abstraction. With the aid of abstract basic classes, software modules can be created that have a defined, unchangeable implementation but whose behavior can be freely altered via polymorphism and inheritance.

 

Here’s an example of how not to do this:

 

void draw(Form f) {

   if (f.type == circle) drawCircle(f);

   else if (f.type == square) drawSquare(f);

...

 

This example isn’t open for extensions. If you want to draw additional shapes, the source code of the drawing method would have to be modified. A better approach is to move the drawing of the shape into the actual shape class.

 

Dependency Inversion

The principle of dependency inversion states that you should not permit any direct dependencies, but instead only dependencies of abstractions. This ultimately makes it easier to replace building blocks. You should decouple direct dependencies between classes using methods such as the factory method. One of the core reasons (not the only, obviously) for using a dependency inversion is an architectural style with which it makes it very easy to write mocked unit tests, thus making a TDD approach more viable.

 

Let’s look at an example: Assume that you want to develop a Windows application that reads the weather forecast from the internet and displays it graphically. On the basis of the principles described previously, you relocate the functionality that takes care of the handling of the Windows API into a separate library.

 

 

The module for displaying the weather data is now dependent on the Windows API, but the API isn’t dependent on the display of the weather data. The Windows API can also be used in other applications. However, you can only run your weather display application under Windows. In its current form, it won’t run on a Mac or in a Linux environment.

 

This problem can be solved with the aid of an abstract OS module. This module specifies which functionality the specific implementations have to provide. In this case, the OS abstraction isn’t dependent on the specific implementation. You can add a further implementation (e.g., for Solaris) without any problems.

 

 

Interface Segregation Principle (ISP)

For multiple uses of an extensive interface, it can be useful to separate the interface into several more specific interfaces based on the following:

  • Semantic context
  • Area of responsibility
  • User groups

This type of separation reduces the number of dependent users and thus also the number of possible consequent changes. Furthermore, a number of smaller, more focused interfaces are easier to implement and maintain.

 

Resolving cyclic dependencies

Cyclic dependencies make it more difficult to maintain and modify systems, and prevent separate reuse.

 

 

Unfortunately, cyclic dependencies can’t always be avoided. However, in the example shown above, you can do the following:

  1. Separate the parts of A that are used by C in the form of abstraction CA.
  2. Dissolve the cyclic dependency by means of an inheritance relationship from A to the abstraction CA.

Liskov’s Substitution Principle

Liskov’s substitution principle, named after Barbara Liskov, was originally defined as follows:

 

Let q(x) be a property provable about objects x of type T. Then q(y) should be true for objects y of type S, where S is a subtype of T.

 

This principle states that a basic class should always be capable of being replaced by its derived classes (subclasses). In such a case, the subclass should behave in exactly the same way as its parent class.

 

If a class doesn’t comply with this principle, it’s quite likely that it uses inheritance incorrectly in terms of generalization/specialization.

 

The capability of many programming languages to overwrite methods can be potentially problematic. If the method’s signature is changed—for example, by changing visibility from public to private—or a method suddenly no longer throws exceptions, unwanted behavior can result, and the substitution principle is then violated.

 

An example for violation of this principle, which at first glance isn’t so obvious, is to model a square as a subclass of a rectangle—in other words, the square inherits all the attributes and methods of the rectangle.

 

 

First of all, we notice that a square only requires a single attribute, namely, the length of its sides. A square, however, can also be defined using two side lengths, which then requires you to check that the property of a square (i.e., all sides of equal length) is fulfilled. To do this, the methods setHeight and setWidth have to be modified so that they set the height and width of the square to the same value.

 

Initially, this doesn’t appear to be a problem. A crucial problem first arises in the use of a square in place of a rectangle because a rectangle can’t always be replaced by a square. For example: A picture is to be given a rectangular frame. The client passes the height and width of the picture, the coordinates of its top-left corner, and a square (not a rectangle) to the drawFrame method. The drawFrame method now calls the setHeight and setWidth operations of the square, and the result is a square with the side length equal to the width of the picture. This is because the setWidth method sets the width and height of the square to the same value.

 

Editor’s note: This post has been adapted from a section of the book Software Architecture Fundamentals: iSAQB-Compliant Study Guide for the Certified Professional for Software Architecture—Foundation Level Exam by Mahbouba Gharbi, Arne Koschel, Andreas Rausch, and Holger Tiemeyer.