Image of Software design principles

ADVERTISEMENT

Table of Contents

Introduction

Software design has always been the most important phase in the development cycle, the more time you put on designing a resilient and flexible architecture, the more saving you will get in the future when changes arise. Requirements always change, software will become legacy if no features are added or maintained on regular basis, and the cost of these changes are determined based on the structure and architecture of the system. In this article, we’ll discuss the key design principles which help in creating an easily maintainable and extendable software.

1. A practical scenario

Suppose that your boss asks you to create an application which converts word documents to PDF. The task looks simple and all you have to do is to just lookup a reliable library which converts word documents to PDF and plug it in inside your application. After doing some research you ended up using Aspose.words framework and created the following class:

/**
 * A utility class which converts a word document to PDF
 * @author Hussein
 *
 */
public class PDFConverter {
 
    /**
     * This method accepts as input the document to be converted and 
     * returns the converted one.
     * @param fileBytes
     * @throws Exception 
     */
    public byte[] convertToPDF(byte[] fileBytes) throws Exception
    {
        // We're sure that the input is always a WORD. So we just use 
        //aspose.words framework and do the conversion.
        
        InputStream input = new ByteArrayInputStream(fileBytes);
        com.aspose.words.Document wordDocument = new com.aspose.words.Document(input);
        ByteArrayOutputStream pdfDocument = new ByteArrayOutputStream();
        wordDocument.save(pdfDocument, SaveFormat.PDF);
        return pdfDocument.toByteArray();
    }
}

Life is easy and everything is going pretty well !!

Requirements change as always

After few months, some client asks to support excel documents as well, so you did some research and decided to use Aspose.cells. Then you go back to your class and added a new field called documentType and modified your method like the following:

public class PDFConverter {
    // we didn't mess with the existing functionality, by default 
    // the class will still convert WORD to PDF, unless the client sets 
    // this field to EXCEL.
    public String documentType = "WORD";
 
    /**
     * This method accepts as input the document to be converted and 
     * returns the converted one.
     * @param fileBytes
     * @throws Exception 
     */
    public byte[] convertToPDF(byte[] fileBytes) throws Exception
    {
        if(documentType.equalsIgnoreCase("WORD"))
        {
            InputStream input = new ByteArrayInputStream(fileBytes);
            com.aspose.words.Document wordDocument = new com.aspose.words.Document(input);
            ByteArrayOutputStream pdfDocument = new ByteArrayOutputStream();
            wordDocument.save(pdfDocument, SaveFormat.PDF);
            return pdfDocument.toByteArray();
        }
        else
        {
            InputStream input = new ByteArrayInputStream(fileBytes);
            Workbook workbook = new Workbook(input);
            PdfSaveOptions saveOptions = new PdfSaveOptions();
            saveOptions.setCompliance(PdfCompliance.PDF_A_1_B);
            ByteArrayOutputStream pdfDocument = new ByteArrayOutputStream();
            workbook.save(pdfDocument, saveOptions);
            return pdfDocument.toByteArray();
        }
    }
}

This code will work perfectly for the new client and will still work as expected for the existing clients, however some bad design smells start to appear in the code which denotes that we’re not doing it the perfect way and we will not be able to modify our class easily when a new document type is requested.

  • Code repetition: As you see, similar code is being repeated inside if/else block and if we managed someday to support different extensions, then we will have a lot of repetitions. Also if we decided later on for example to return a file instead of byte[] then we have to do the same change in all the blocks.
  • Rigidity: All the conversion algorithms are being coupled inside the same method, so there is a possibility if you change some algorithm, others will be affected.
  • Immobility: The above method depends directly on documentType field, some clients would forget to set the field before calling convertToPDF() so they will not get the expected result, also we’re not able to reuse the method in any other project because of its dependency on the field.
  • Coupling between high level module and the frameworks: If we decide later on for some purpose to replace Aspose framework with a more reliable one, we will end up modifying the whole PDFConverter class and many clients will be affected.

Doing it the right way

Normally, not all developers are able to expect the future changes , so most of them would implement the application exactly as we implemented it the first time, however after the first change the picture becomes clear that similar future changes will arise. So instead of hacking it with if/else block, good developers will manage to do it the right way in order to minimize the cost of future changes. So we create an abstract layer between our exposed tool (PDFConverter) and the low level conversion algorithms, and we move every algorithm into a separate class as the following:

/**
 * This interface represents an abstract algorithm for converting
 * any type of document to PDF.
 * @author Hussein
 *
 */
public interface Converter {
 
    public byte[] convertToPDF(byte[] fileBytes) throws Exception;
}
/**
 * This class holds the algorithm for converting EXCEL
 * documents to PDF.
 * @author Hussein
 *
 */
public class ExcelPDFConverter implements Converter{
 
    public byte[] convertToPDF(byte[] fileBytes) throws Exception {
        InputStream input = new ByteArrayInputStream(fileBytes);
        Workbook workbook = new Workbook(input);
        PdfSaveOptions saveOptions = new PdfSaveOptions();
        saveOptions.setCompliance(PdfCompliance.PDF_A_1_B);
        ByteArrayOutputStream pdfDocument = new ByteArrayOutputStream();
        workbook.save(pdfDocument, saveOptions);
        return pdfDocument.toByteArray();
    };
}
WordPDFConverter.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
 * This class holds the algorithm for converting WORD 
 * documents to PDF.
 * @author Hussein
 *
 */
public class WordPDFConverter implements Converter {
 
    @Override
    public byte[] convertToPDF(byte[] fileBytes) throws Exception {
        InputStream input = new ByteArrayInputStream(fileBytes);
        com.aspose.words.Document wordDocument = new com.aspose.words.Document(input);
        ByteArrayOutputStream pdfDocument = new ByteArrayOutputStream();
        wordDocument.save(pdfDocument, SaveFormat.PDF);
        return pdfDocument.toByteArray();
    }
}
public class PDFConverter {
 
    /**
     * This method accepts as input the document to be converted and 
     * returns the converted one.
     * @param fileBytes
     * @throws Exception 
     */
    public byte[] convertToPDF(Converter converter, byte[] fileBytes) throws Exception
    {
        return converter.convertToPDF(fileBytes);
    }
}

We force the client to decide which conversion algorithm to use when calling convertToPDF().

2. What are the advantages of doing it this way !!

  • Separation of concerns (high cohesion/low coupling): PDFConverter class now knows nothing about the conversion algorithms used in the application, its main concern is to serve the clients with the various conversion features regardless how the conversion is being done. Now that we’re able anytime to replace our low level conversion framework and no one would even know as long as we’re returning the expected result.
  • Single responsibility: After creating an abstract layer and moving each dynamic behavior to a separate class , we actually removed the multiple responsibility that convertToPDF() method previously had in the initial design, now it just has a single responsibility which is delegating client requests to abstract conversion layer. Also each concrete class of Converter interface has now a single responsibility related to converting some document type to PDF. As a result, each component has one reason to be modified, hence no regressions.
  • Open/Closed application: Our application is now opened for extension and closed for modification, whenever we want in future to add support for some document type, we just create a new concrete class from Converter interface and the new type become supported without the need to modify PDFConverter tool since our tool now depends on abstraction.

3. Design principles learned from this article

Following are some best design practices to follow when building an architecture for application:

  • Divide your application into several modules and add an abstract layer at the top of each module.
  • Favor abstraction over implementation: always make sure to depend on abstraction layer, this will make your application open for future extensions, the abstraction should be applied on the dynamic parts of the application (which most likely to be changed regularly) and not necessarily on every part since it complicates your code in case of overuse.
  • Identify the aspects of your application that vary and separate them from what stays the same.
  • Don’t repeat yourself: always put the duplicate functionalities in some utility class and make it accessible through the whole application, this will make your modification a lot easier.
  • Hide low level implementation through abstract layer: low level modules have a very high possibility to be changed regularly, so separate them from high level modules.
  • Each class/method/module should have one reason to be changed, so always give a single responsibility for each of them in order to minimize regressions.
  • Seperation of concerns: each module knows what other module does, but it should never know how to does it.

Summary

Software design has always been the most important phase in the development cycle, the more time you put on designing a resilient and flexible architecture, the more saving you will get in the future when changes arise. Requirements always change, software will become legacy if no features are added or maintained on regular basis, and the cost of these changes are determined based on the structure and architecture of the system. In this article, we’ll discuss the key design principles which help in creating an easily maintainable and extendable software.

Next Steps

If you're interested in learning more about the basics of Java, coding, and software development, check out our Coding Essentials Guidebook for Developers, where we cover the essential languages, concepts, and tools that you'll need to become a professional developer.

Thanks and happy coding! We hope you enjoyed this article. If you have any questions or comments, feel free to reach out to jacob@initialcommit.io.

Final Notes