Book Image

Software Architecture with Spring 5.0

By : René Enríquez, Alberto Salazar
Book Image

Software Architecture with Spring 5.0

By: René Enríquez, Alberto Salazar

Overview of this book

Spring 5 and its ecosystem can be used to build robust architectures effectively. Software architecture is the underlying piece that helps us accomplish our business goals whilst supporting the features that a product demands. This book explains in detail how to choose the right architecture and apply best practices during your software development cycle to avoid technical debt and support every business requirement. Choosing the right architecture model to support your business requirements is one of the key decisions you need to take when a new product is being created from scratch or is being refactored to support new business demands. This book gives you insights into the most common architectural models and guides you when and where they can be used. During this journey, you’ll see cutting-edge technologies surrounding the Spring products, and understand how to use agile techniques such as DevOps and continuous delivery to take your software to production effectively. By the end of this book, you’ll not only know the ins and outs of Spring, but also be able to make critical design decisions that surpass your clients’ expectations.
Table of Contents (21 chapters)
Title Page
Copyright and Credits
Packt Upsell
Contributors
Preface
Index

Software architecture principles


Software architecture should improve by following two simple principles that are often difficult to achieve:

  • Low coupling
  • High cohesion

No matter what programming language, paradigm, or tools you are using to architect your applications, these two principles should guide you when building your software architecture components.

In order to build the components that will shape your architecture, it's always worth following the guidelines. These are still relevant, even after many years of existence, and they should always be considered when components are being created. In this section, I'm talking about SOLID principles and Conway's law, which we will discuss in more detail later in this chapter. It is now time to look at what components are in more detail.

Components

A component is a set of functions, data structures, and algorithms that solve one problem. This means that all the code and artifacts that are used to build the component have a high cohesion with each other; the rule here is that the classes or files that create a component should change at the same time and for the same reason.

Software architecture is built using many components, and you should not be worried about having an excessive quantity of these. The more components you write, the more freedom there is to assign them to different developers or even to different teams. Large software architectures can be created using many smaller components that can be developed and deployed independently of each other.

Once we connect these components to each other, they allow us to create the desired software architecture.

As shown in the following diagram, we can see the components as pieces of a puzzle that come together to form an application:

Components forming a larger application

The connected components define application architectures, and their designs describe how each component has been created internally. It's here that pattern designs and SOLID principles must be used to create good designs.

Low coupling

Low coupling refers to the degree to which components depend on each other by their lower structures instead of their interfaces, creating a tight coupling among them. Let's make this easier to understand by using a simple example. Imagine that you need to work on the next user's story:

As a bank customer, I want to receive my bank statement by email or fax in order to avoid having to open the bank application.

As you may discover, the developer should work on two things to solve this problem:

  • Adding the ability to save the user's preferences in the system
  • Making it possible to send the bank statement to the customer by using the requested notification channels

The first requirement seems quite straightforward. To test this implementation, we would use something fairly simple, such as the following code:

@Test 
public void 
theNotificationChannelsAreSavedByTheDataRepository() 
throws Exception 
{ 
  // Test here 
} 

For the second requirement, we will need to read these preferred notification channels and send the bank statement using them. The test that will guide this implementation will look like the following:

@Test 
public void 
theBankStatementIsSendUsingThePreferredNotificationChannels() 
 throws Exception 
{ 
  // Test here 
} 

It is now time to show a tightly coupled code in order to understand this problem. Let's take a look at the following implementation:

public void sendBankStatement(Customer customer) 
{
  List<NotificationChannel> preferredChannels = customerRepository
.getPreferredNotificationChannels(customer);
BankStatement bankStatement = bankStatementRepository
  .getCustomerBankStatement(customer);
preferredChannels.forEach
  (
    channel -> 
    {
if ("email".equals(channel.getChannelName())) 
      {
notificationService.sendByEmail(bankStatement);
} 
      else if ("fax".equals(channel.getChannelName())) 
      {
notificationService.sendByFax(bankStatement);
}
    }
  );
}

 

 

Note how this code is tightly coupled with the implementation of the NotificationService class; it even knows the name of the methods that this service has. Now, imagine that we need to add a new notification channel. To make this code work, we will need to add another if statement and invoke the correspondent method from this class. Even when the example is referring to tightly coupled classes, this design problem often occurs between modules.

We will now refactor this code and show its low-coupled version:

public void sendBankStatement(Customer customer) 
{
  List<NotificationType> preferredChannels = customerRepository
.getPreferredNotificationChannels(customer);
BankStatement bankStatement = bankStatementRepository
.getCustomerBankStatement(customer);
preferredChannels.forEach
  (
    channel ->
notificationChannelFactory
    .getNotificationChannel(channel)
    .send(bankStatement)
  );
}

This time, the responsibility to get a notification channel is passed to the Factory class, no matter what kind of channel is needed. The unique detail that we need to know from the channel class is that it has a send method.

The following diagram shows how the class that sends notifications was refactored to send notifications using different channels and support an interface in front of the implementations per notification channel:

Classes after refactoring

This small but significant change has to lead us to encapsulate the details of the mechanism used to send notifications. This exposes only one well-defined interface that should be used by the other classes.

Although we have shown this example using classes, the same principle is applicable to components, and the same strategies should be used to implement them and avoid coupling among them.

High cohesion

The principle of high cohesion also has a pretty simple definition: one component should perform one and only one well-defined job. Although the description is pretty simple, we often tend to get confused and violate this principle.

In the previous example, we had NotificationService, which was in charge of sending notifications by email and fax. The word and can be helpful for us when it comes to identifying the violation of this principle. Now that we have two different classes (one per notification channel), it's fair to say that our classes only have one responsibility.

Again, the same is true for components, and another reason to keep the same idea with them is that you will likely have each component accomplishing only one specific requirement. For example, what would happen if all our customers just wanted to receive their bank statements by email; do you think it's okay to depend on a class that has the ability to send faxes too?

Although the previous question may seem unimportant, imagine that you solved an existing issue related to sending notifications using faxes as a notification mechanism, and a new issue was then introduced into the mechanism in order to send email notifications by mistake.

Remember that components shape your software architecture, and architects should design them in a way that maximizes team productivity. Aligning your components to the high-cohesion principle is an excellent way to separate them and allows teams to work independently on different parts of your application. This ability to create various components with clear responsibilities will make it easier when solving other issues and adding new features, and will also make you less prone to introducing bugs.

With regards to the previous example, you are probably wondering why the NotificationChannel class is apparently sending notifications with a BankStatement parameter.

Common sense leads us to believe that we need to replace this class with any other generic type. It can be helpful to allow the application to send different kinds of notifications, and not only bank statements: this may include drawbacks, or when a new deposit is received in the account. Even though the idea of supporting incoming requirements looks like something you might want to include in the program at this stage, the application doesn't currently need this ability. This is why it is not necessary for us to add this feature right now. Instead, this design should evolve when this becomes necessary; in this way, we are sticking to the KISS principle (https://www.techopedia.com/definition/20262/keep-it-simple-stupid-principle-kiss-principle) and following the directions of only building the most basic features to make the application work.