In previous chapters, we explored many different aspects of Java application development. Starting from the Software Development Life Cycle (SDLC), which includes requirements collection and architecture design, we focused on many different technological aspects, including frameworks and middleware.
At this point, several cross-cutting concerns need to be looked at, regardless of the kind of application we are building and the architectural style we choose.
In this chapter, we are going to explore such aspects. We will start with things such as the maintenance of source code (including versioning and branching strategies), ranging through to some core topics related to deploying, Continuous Integration/Continuous Delivery (CI/CD), and other concepts related to the SDLC in its entirety.
In this chapter, these are the topics we are going to cover:
I'm almost sure that in your professional life, you will already have had the opportunity to become familiar with these topics. Indeed, these are often taken for granted.
Nevertheless, by the end of this chapter, you will have a complete view of the entire process, which will be really useful in structuring and maintaining a functional and efficient software toolchain.
Now, let's start with Source Code Management (SCM).
You can find the source code used in this chapter here: https://github.com/PacktPublishing/Hands-On-Software-Architecture-with-Java/tree/master/Chapter13.
SCM is a pretty basic concept and should be considered, of course, mandatory in any software project (including very small ones). Nowadays, SCM is synonymous with Git (more on that soon); however, many alternatives have been used over the years, including Concurrent Versions System (CVS) and Apache Subversion (SVN).
The basic function of SCM is backing up, sharing, and versioning source code. However, there are many nuances to these features. So, let's have a closer look at Git.
Git was created by Linus Torvalds, the creator of the Linux OS, as a tool for supporting the development of the OS itself.
Apart from the history of the project, Git has many interesting characteristics that make it a de facto standard:
Git encompasses a lot of different commands and features; however, the basics for using it are as follows:
These Git commands and, in particular, the concept of branching and tagging are very powerful tools. How they are used has become more and more structured over time, creating some specified workflows. In the next sections, we'll see some ideas on this.
Despite the name, Git Flow (and other similar techniques) is not a prerequisite of Git, and, in theory, could also be implemented with SCM tools, which are different from Git. However, it is common to implement this kind of technique together with Git.
Git Flow is an articulated way of managing and storing developments, creating releases, and, in general, structuring the way the code is handled.
Git Flow is based on a number of branches coexisting constantly and can be implemented manually or by using some tools created to support such a way of working.
The core line where the code is stored is called the Main branch. The developers are not supposed to work directly on this branch. Instead, a develop (Dev) branch is created from it to store the work in progress. In order to work on a feature, each developer copies the Dev branch into a purposely created Feature branch, which is created to contain a specific feature. When a feature is completed, it's merged back into the Dev branch. In theory, since just a few features are developed, the merge operation should not be too difficult (since not much code has changed). The following diagram illustrates this:
As we can see, the simplest situation is when features are developed one after the other, hence the feature we just developed has to be merged back. However, in real situations, it's common to have more than one feature developed in parallel, so the merge back into the Dev branch can be slightly more difficult.
When enough features are developed (and have been merged into the Dev branch), a new branch is created from the Dev branch, called Release. The Release branch should have some kind of a feature freeze, meaning that all the code committed into this branch must only have the goal of releasing and not adding any new features. This means that while tests are going on against the code in the Release branch, developers are supposed to commit bug fixes (if any) in this branch.
Other files needed for the release (such as documentation and scripts) can be added there. When the release is ready, the code in the branch will be tagged (that is, freezed to a specific version). Then, the Release branch is merged back into the Main and Dev branches so that the developments for the upcoming versions can begin, as shown in the following diagram:
As we can see, when working on a release, all the code is supposed to be modified in the Release branch itself for fixing the issues that prevent this particular release from going into production. Once everything is ready and the production release is successful, the code in the Release branch (including the freezed code for that release plus the bug fixes, if any) is merged back into the Main and Dev branches.
If an issue happens in production, an ad hoc Hotfix branch is created from the Main branch for the purpose of production fixes and merged back as soon as possible, as shown here:
As seen in the diagram, in the case of a hotfix, the code should come from the Main branch and the fixes must be done in the Hotfix branch. The changes must then be merged back to both the Main and Dev branches.
Git Flow seems a bit difficult, and indeed it requires a lot of different branches and merge operations. But it's also considered not very well suited for modern application development techniques, such as CI/CD and DevOps. For such situations, trunk-based development is considered a better choice.
Trunk-based development is much simpler than Git Flow. Basically, every developer works on the same branch (the main branch, usually). They are allowed to create branches for local developments, but it's advised to make them as short-lived as possible, and merge them back to the main branch as soon as possible (at least daily). This needs to be done so that the developments are consistent, the tests should pass, and the changes should not break anything else in the project.
With this in mind, trunk-based development is often seen as a perfect pair with CI/CD (more on this later in the chapter). It is possible (and common) to have automated processes constantly checking for the integrity of the main branch (such as after every merge), and in the case of tests failing, changes could be reverted; or, someone in the team (usually the developer of such changes) should focus on fixing the issues. The main branch can be released in production at any time (carrying all the latest developments). Before each release, the code is tagged for traceability and reproducibility of the release.
Trunk-based development, other than being easier to implement and maintain, requires less effort for change management, as merges are smaller and they happen quite often.
On the other hand, it requires great experience and dedication from every project contributor as it increases the possibility of bugs or other bad code slipping into the main trunk (and, theoretically, into production). A related topic to branching strategies is the versioning standard.
As we said, during each release, the source code is frozen (usually with a tag) and uniquely identified for maintenance purposes.
However, there are many different theories on what the best way for versioning releases is, as in choosing a unique identifier.
A method that I find particularly elegant and effective is semantic versioning.
Semantic versioning is commonly used in open source projects and basically associates each release with three numbers, in the form of x.y.z (for example, 1.2.3). Changes to each of these numbers have a precise meaning:
An increase in the major version implies that minor and patch versions are reset to 0. So, if we make big changes for version 1.2.3 (by breaking the APIs), the next release should be 2.0.0.
Similarly, an increase in the minor version resets the patch version to 0, so for version 1.2.3, if there are new features that are backward compatible, we go to version 1.3.0. Needless to say, each version can go to double figures with no impact on the other versions. Hence, an increase in the minor version of the software in version 1.9.3 means going to version 1.10.0.
After the three numbers compose the version, it is possible to add an optional label. Common labels are RELEASE (identifying a version released in production), ALPHA (identifying a preliminary version, not intended for production), and Release Candidate (RC) (this is something almost ready for production, but likely needs some more testing).
It is also a common convention to set the major version to 0 in order to identify the first project draft (such as a prototype not intended to be stable).
In this section, we have learned some interesting concepts. Starting with Git, which is the de facto standard, and SCM, we learned about a couple of different branching strategies, and we had a look at a standard way for versioning releases. Now, our next step is to make some considerations about testing.
In Chapter 4, Best Practices for Design and Development, we had a look at Test-Driven Development (TDD), quickly touching on the concept of unit testing. Now is the right time to make some deeper considerations around the concept of testing and return to some topics that we have taken for granted so far.
Unit testing is the most basic technique for software quality assurance and, as we have seen, the tool behind TDD.
Unit testing aims to provide testing (usually automated) for the smallest unit of identifiable software. In the Java world, this means testing at a class and method level. The tests involve calling the method with a defined set of inputs and checking (with assertions) that the output complies with the expectation (including expected failures).
The reasoning behind it is that each method is tested individually, so the tests can be simple and pervasive. This also allows bugs to be identified early and in the exact spot where they are introduced (at least in the exact method). The limitation of this approach is that it doesn't easily detect bugs caused by corner cases or interaction between complex systems, or with external systems.
In the Java world, unit testing means JUnit, which is a very famous library widely used for implementing unit tests and more. Let's learn more about it.
JUnit is the de facto standard for unit testing in Java. The current version at the time of writing is version 5. JUnit provides some standards and facilities for defining unit tests and integrating them into common toolchains, such as Maven and Gradle. JUnit is also easy to run from a common IDE, such as IntelliJ.
The Maven standard defines that the test classes must be placed in the src/test/java folder, whereas the application code is supposed to stay in the src/main/java folder.
In this way, the test classes can mirror the same package structure as the application files, and in the release phase, the test classes can then be discarded and not be part of the release artifacts.
JUnit automatically considers (and runs) tests contained in classes whose name starts or ends with Test.
Each test method is identified by the @Test annotation. It's possible to annotate some method for setting up resources before tests, with annotations such as @BeforeAll and @BeforeEach. At the same time, it's possible to clean up things after tests, using @AfterAll and @AfterEach. Test execution can be controlled by using @Order.
Moreover, JUnit provides a set of facility methods, such as AssertEquals, AssertTrue, and AssertFalse, which can be used to check for the expected results.
JUnit execution is commonly integrated as a step into a build chain (acting as a part of a Maven build or of a more complex pipeline). You can constantly have a view of what is working and what is failing, often with a visual representation with green and red lights for building reports.
Now, we have a simple class such as the following:
package it.test;
public class HelloWorld {
private String who;
public HelloWorld() {
this.who="default";
}
public String getWho() {
return who;
}
public void setWho(String who) {
this.who = who;
}
public String doIt()
{
return "Hello "+ this.who;
}
}
The preceding class basically has a field with a getter and setter, and a method to do the classic hello world (with a string concatenation). The unit test class associated with the preceding class is as follows:
package it.test;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
public class HelloWorldTest {
HelloWorld hello;
@BeforeEach
public void buildHello()
{
this.hello= new HelloWorld();
}
...
A few considerations about the preceding test are as follows:
You can also use @BeforeAll, which is executed once before all tests. Also, it's worth noticing that @AfterEach and @AfterAll are available for the teardown of resources that need to be safely closed (such as database connections) or for cleaning up the necessary data IDs.
...
@Test
public void testConstructor()
{
Assertions.assertEquals(this.hello.getWho(),
"default");
}
@Test
public void testGetterSetter()
{
String name="Giuseppe";
this.hello.setWho(name);
Assertions.assertEquals(this.hello.getWho(),
name);
}
@Test
public void testDoIt()
{
String name="Giuseppe";
String expected="Hello "+name;
this.hello.setWho(name);
Assertions.assertEquals(this.hello.doIt(),
expected);
}
}
The previous code can be made more readable by using a static import on Assertions, and then directly using the methods provided by the class.
When running these tests, you can easily see a recap of test execution. By way of an example, by running the mvn clean test command, you should see something similar to this screenshot:
As you can see, the build succeeds, and there is a recap of the executed tests (that were successful). If a test fails, by default, the build fails. If we know that there is a test intentionally failing (because, as an example, the method is not yet implemented, as it happens in the TDD methodology), we can skip that particular test (by annotating it with @Disable) or skip the testing phase completely (which is usually not advised).
In the case of a big project, usually, the testing results are then saved and archived as part of a build process. This may simply mean saving the console output of the build with the test recap (as seen in the preceding screenshot) or using more sophisticated techniques. By using a widespread Maven plugin (Surefire), it's easy to save test results as .xml or .html files, although more complete commercial test suites are able to do similar things.
But this was just about unit testing. To complete our view, it's useful to understand that more ways of testing are possible (and advised). Let's have a look at them in the following sections.
Unit testing, indeed, can be seen as the (basic and essential) lowest step in the testing world. Indeed, as we have already said, unit testing is unable to catch some bugs that depend on more complex interactions between classes. To do so, more testing techniques are usually implemented, such as integration, end-to-end, performance, and User Acceptance Testing (UAT).
Integration testing is the immediate next step after unit testing. While unit testing tests the most atomic modules of software, such as methods and classes, integration testing focuses on the interaction of such modules with each other (but not on the entire system). So, the classes are put together and call each other to check (and realize) more complex testing scenarios. Each test involves more than one method call, usually from different classes.
There is no fixed rule for defining the granularity of each integration test, even if someone completely ditches this testing technique in favor of end-to-end testing (more on this in the next section). My personal suggestion is to at least add integration testing for the more complex functionalities by trying to involve at least two or three classes simulating the core features or at least the ones most impacted by changes (and by issues).
While there are libraries that can be implemented specifically for integration testing (arquillian comes to mind), JUnit is perfectly usable (and widely used) for integration testing, too, by using the same facility (such as assertion and setup methods) as seen in the previous section. Of course, instead of building tests for testing each method and class, more complex interactions are supposed to be implemented by chaining method calls and plugging different classes together.
End-to-end testing, also known as system testing, takes the integration testing ideas a bit further. End-to-end testing involves the testing of each functionality as a whole, including external systems (such as databases), which are commonly dedicated and maintained as part of the testing efforts. The functionality can be defined at many different times, but nowadays usually overlaps with the concept of an API.
End-to-end testing includes calling an API (or triggering a functionality differently) by passing a known set of inputs and then checking the expected outputs. This will include, alongside the API response, also checking the status of external systems that are supposed to be changed (such as things edited in databases or external systems that are supposed to be contacted, such as sending emails).
It is implied that the system is then tested from the outside, as opposed to the other testing techniques seen so far, which are more focused on the source code (and then more looking at the project from the inside).
End-to-end testing provides a good idea of system behavior as a whole, and it's usually less stable than unit and integration testing because a small change in any of the methods can cause failures in many end-to-end tests depending on that specific method (and go undetected or have a smaller impact on unit and integration tests). However, it is also coarser-grained, so it can be a bit more difficult to understand where and why things are breaking.
There are a lot of tools for end-to-end testing, both free and commercial. Such tools are usually more of a kind of standalone platform, as opposed to the tools seen so far (such as JUnit), which are more libraries and frameworks. Moreover, end-to-end testing is basically language-independent, hence, Java projects don't usually need any specific testing tool, as the entry points for testing are APIs or user interfaces. So, any tool capable of interacting at that level can be used.
Commonly used solutions include LoadRunner, a commercial solution originally built by HP and now part of Micro Focus, which is the standard in some projects. Other alternatives are the SmartBear testing suite and other free testing suites, such as JMeter, Cypress, Karate, Gatling, and Selenium. The last two tools are more focused on automating the user interface interactions, which means that there are basic ways to automate the programmatic use of web browsers, simulating a real user accessing a web application, checking all the expected behavior.
Performance testing is a special case of end-to-end testing. Instead of being exclusively focused on the correct implementation of each API (or feature) tested (which is basically taken for granted), performance tests focus on system capacity and the response time under different loads. While the tools can be (and often are) similar to end-to-end testing, the final goal is different. The metrics measured when doing performance testing are the ones described in Chapter 6, Exploring Essential Java Architectural Patterns, in the Designing for large-scale adoption section, and include throughput, response time, and elapsed time.
Performance testing can include the following scenarios:
During a performance test, regardless of the tools and objectives, it's strongly advised to observe the system as a whole, including OS parameters (such as memory, CPU, and network) and external systems, such as databases, in order to check for bottlenecks and understand how the system can be fine-tuned to perform better.
UAT is a crucial step of the testing process, and I'd say of the whole software development process. Technically speaking, it is quite similar to end-to-end testing, by focusing on testing functionalities as a whole. There are, however, some crucial differences. The first one is that the test is supposed to be governed by a functional analyst, business people, or the project sponsor.
This, of course, doesn't mean that these people should be running the tests themselves, but that the test structure (including the acceptance criteria and the priority of the features tested) should be set by those teams, and this is usually done by focusing on the point of view of the end user (hence the name of this phase).
It's accepted that part of this test is done manually, with users directly navigating the application feature as a final user is supposed to. A more deterministic approach is to also run the UAT using automated tools, similar to the ones used in end-to-end testing. Even in this case, it's common to still perform a small part of this phase manually, by doing what is called a smoke test, which is less structured and aimed at giving a general idea of how the application behaves.
Whether being done manually or automated, there is a core difference between UAT and the other tests seen so far, and this difference is that the tests need to be designed around business capabilities. Indeed, each test case is supposed to be related to a specific requirement in order to prove that this requirement is currently implemented in that particular software release. We already discussed this in Chapter 2, Software Requirements – Collecting, Documenting, Managing, where we saw how each software requirement is supposed to be testable.
This is where that loop closes. The successful execution of UAT is the gateway for the production release (hence the word acceptance); if all the tests succeed, it is, of course, safe and accepted to release in production. In case of any failure, a choice needs to be made (usually discussed with an extended team).
If minor issues occur, this may mean that the release will go into production anyway, with several known issues. Of course, if this is not the case and the issues are too many (or related to critical features), then the issues need to be fixed and the production release may be canceled or delayed. UAT is basically the higher rank of tests, but it's important to understand that it's crucial to have a strategy around every other testing technique seen so far; otherwise, it's likely to have software that is not completely tested and prone to errors. However, some other considerations around testing are worth noticing.
In the previous sections, we saw quite a few interesting things on testing, including the different testing phases and techniques. However, there are a number of other considerations that are worth a few words.
As we have seen, in most of the phases of testing (sometimes even in unit testing), external systems may be involved. Databases, mail servers, and web services are common examples.
There are a number of different techniques for dealing with such external systems in a testing phase. The easiest one, better suited for some specific testing phases, such as unit tests, is to simply mock such systems. This means implementing special custom classes that simply simulate the interaction with such systems, instead of just providing fake values.
A very widespread library for mocking in Java is Mockito, which offers a simple but very complete setup to implement methods that react to the requests in a programmable way, simulating the behavior of external systems.
Mocking is handy to use because it's mostly driven by code and requires minimal maintenance. However, as it's easy to understand, it provides limited effectiveness in tests because it tests just a small part of the interaction, often neglecting some aspects such as the connection to the external system (and things that may go bad there), and in general, doesn't test against real (or close to real) systems.
A step further is to effectively use an external system in tests, but a simplified one. The most common example is using H2 (the embeddable database we saw in Chapter 7, Exploring Middleware and Frameworks) in place of a full-fledged database system. The reason behind it is that it may be harder (and more expensive) to use compared to the real system, while such simplified tools are usually easier to automate and use in a testing environment.
However, as we discussed in Chapter 9, Designing Cloud-Native Architectures, when talking about 12-factor applications, using external services (backing services, as defined in that context) different from the production ones should be considered a source of potential instability as, of course, the behavior may be different from real systems. So, especially in phases such as end-to-end testing and UAT, it's strongly advised to use external systems that are as close as possible to the production ones. This leads us to the next consideration on ephemeral testing.
Ephemeral testing is a technique for creating complete test environments when needed. This basically means that the set of components needed for testing, including the application and the external systems, is created on-demand before each test runs, populated with the data and the configuration needed for the test execution.
Such environments can then be disposed of after each test runs, avoiding wasting computational resources when not needed. This paradigm is particularly suited for IaaS and PaaS environments (as seen in Chapter 9, Designing Cloud-Native Architectures) because such infrastructures will facilitate the scripting and automation around environment creation and disposal. IaaS and PaaS are also suited to recreating not only the application components themselves but also the external services (such as databases), and so are a good way to overcome the limitations that we have described in the previous section, and in particular with mocks.
Testcontainers (www.testcontainers.org) is an open source framework very well suited for this kind of scenario. It supports JUnit (as well as other testing frameworks) and provides throwaway containerized instances of testing utilities (such as common databases, Selenium browsers, and more).
But having all the right components is not the only consideration to be made in order to have a meaningful and complete testing strategy.
A crucial topic of testing is coverage. Code coverage basically implies that every line of code, including the ones reached after if conditions, loops, and so on, is hit by at least a test case.
These kinds of metrics are not easy to measure manually and indeed are commonly calculated by relying on external tools. The most commonly used technique by such tools is bytecode instrumentation, which uses special features of the JVM to check code execution per line, as a result of tests running. Common libraries used for calculating code coverage, such as Cobertura and JaCoCo, use a similar approach.
Code coverage is the baseline of test completeness: a certain threshold must be defined, and a lower coverage should be considered as incomplete testing, especially when creating new functions and modules. But code coverage doesn't ensure that all the features are tested, nor that the data used for tests is complete and variable enough. Hence, a further concept—test coverage—must be introduced.
Test coverage is, in my opinion, a bit less scientific to calculate. Whether code coverage is exactly measurable (even if it requires tools) as the percentage of lines of code executed during tests versus the total lines of code, test coverage revolves around many different points of view. Some common ones are as follows:
Both code coverage and test coverage should be constantly measured and possibly improved. That implies covering the code added with new features (if any), and checking against the bugs found in the current software releases in order to understand whether there is a way to improve test coverage to check for such bugs in the future. Customer reports are particularly useful in this sense.
Most of us are familiar with issues in the applications we use (especially in mobile applications) when, following an error message, there is the opportunity to send details of the error to the application team. By doing so, the application team has the opportunity to check for the particular conditions (inputs used, device used, and software version) at the time of that particular error, and can potentially extend the test suite (and, hence, the test coverage) to check for similar situations and avoid this family of errors in the future.
Last but not least, it's important to understand when to run what kind of tests.
Since running a whole test suite (including acceptance tests run by human operators) could be expensive and time-intensive, it's a common choice to have different test suites run in different situations. If we are fixing a minor bug or adding a small functionality, then we can probably take the risk of not testing the whole application, but just a subset.
However, with test automation becoming more and more pervasive and disposable test environments made possible by the cloud, the advice is to test as much as possible, especially for performance. In this way, it will become easier to understand whether the release we are testing introduces any performance issues. This concept, taken to the extreme, is called continuous testing, and basically implies running the complete suite of tests, in an automated way, after every code or configuration change (even the smallest one).
In this section, we have seen a complete overview of the different testing techniques and phases. Starting with unit testing, we also explored integration, end-to-end, and performance tests.
In the next section, we are going to talk about a step that is contiguous to (and mutually dependent on) testing: deployment.
Software deployment is a very broad term, and can extensively be used to refer to the whole software life cycle, from development to release into production. However, in this particular context, I am referring to the deployment phase as the one in which the software is compiled and opportunely packaged, and the right configurations are applied. The software is then supposedly run and made ready for users to access (which is part of the process of releasing; more on this in a couple of sections). While we already mentioned some of these topics in this book, I would like to highlight a couple of them, useful for the purpose of this chapter.
The phase of building the code, intended as compilation and packaging into a deployable artifact (.jar, .war, and .ear in the case of Java), is done by utilities shipped with the JDK (in particular, the javac tool).
However, this process often includes at the very least the management of dependencies, but in the real world, many other steps can be involved, such as code formatting, the parsing of resources or configuration files, and the execution of unit tests (as seen before).
A widely used technology to perform all of those steps, mentioned and used many times in this book, is Apache Maven.
While I assume that most of you already know and have used Maven, I think it is relevant to highlight some features that are useful to consider from an architect's perspective:
As said, Maven is basically a standard technology for building in the Java world, though it's not the only one. Ant is another option that used to be widely used some years ago and is more similar to scripting and less flexible. It has been progressively abandoned for its verbosity and a number of shortcomings in dependency management.
Gradle is a modern alternative to Maven, mostly widespread in the context of Android application development. However, the concept of code building raises the need of archiving and managing the dependencies, in the form of built artifacts (which, as we have seen, are referenced uniquely into the pom.xml configuration files). Let's have a quick overview of this concept.
Software artifacts are essential, both as part of the building process of bigger components (as dependencies) and to be directly deployed (as an example, to production environments). While it's possible to directly manage such contents (which, in the Java world, are basically .jar, .ear, and .war files) in filesystem folders, it's way better to do so in optimized systems. Such systems are called artifact repositories.
An artifact repository often provides many advanced features, including the following:
Two very famous implementations of Maven artifact repositories are JFrog Artifactory and Sonatype Nexus.
It's worth noting that the container technology (as discussed in Chapter 9, Designing Cloud-Native Architectures) is often seen as an extension (but not a replacement) of Java artifacts. Indeed, a container image contains a complete application component, including dependencies at an OS level, where JVM and other middleware are needed. Moreover, a container image is immutable and can be uniquely identified (by using a version and a signature), so it is really similar to a Java artifact and raises similar needs in terms of management.
For this reason, the ideas exposed about Java artifact repositories can be extended to container repositories. It's not incidental that both the mentioned technologies (Artifactory and Nexus) have extensions used for handling containers.
Popular container repositories available online include Docker Hub and Quay.io.
As we have looked at code compiling and the management of artifacts, it is now time to complete the deployment. This may include different steps, which ultimately aim to install the artifacts in the right places, perform some configurations, and execute the software. The details of these steps may vary heavily depending on the technology used:
As we have seen, different technologies require slightly different ways to complete deployment and effectively distribute and run the software packages. If you consider that more steps can be required, including the configuration of external systems such as databases and IaaS or PaaS systems, it's often a good idea to orchestrate those steps in a unified way. This is one of the characteristics of CI/CD.
CI/CD is the process of automating most of the steps seen so far (and sometimes some more) in order to straighten the process and have a complete overview of it. Since the process includes many steps executed in a mostly sequential way, the tool providing it is commonly called a pipeline.
A typical CI pipeline includes these steps, usually executed sequentially:
CD includes a few further steps, focused on deployment (also, in this case, usually executed in a sequence):
Here is a simplified sample pipeline:
In the preceding diagram, you can see a simplified pipeline example. There are a few steps (Automated tests and Artifact archiving) that are performed in parallel. Moreover, between Staging Deployment and Prod Deployment, some actions may happen, such as UAT and manual approval.
CD is considered a further extension of the pipeline, and basically includes the deployment of the environments in production environments. This may happen in a completely automated way (with no manual intervention), or it may require manual approval by a release manager (in more traditional environments).
It's of course intended that a failure in any step usually means that the pipeline stops and the release fails. It's also worth noticing that deployment in production does not necessarily mean that the software is released and available to users, as we will see soon. But first, it is worth having a look at widespread pipeline implementations.
It's almost impossible to talk about CI/CD without mentioning Jenkins.
Jenkins is a complete automation server, which is basically a synonym of CI/CD pipelines.
It's written in Java and deployed in a servlet container (usually Tomcat). It's possible to define pipelines in Jenkins, using a domain-specific language, which describes each step with the required parameters. Through a system of plugins, steps in Jenkins can do many different things, including compilation using Maven, performing SSH commands, and executing test suites.
Jenkins can then display the pipeline execution results, archive them, and optionally send notifications (as an example, in case of a build failure).
While still being widely used, Jenkins is nowadays famous for having a monolithic architecture (although some steps can be delegated to agents) and for being resource-intensive.
Attempts to create alternative pipeline software, with a more modern design and better performances in a cloud environment, are currently underway. The most famous ones are Jenkins X and Tekton.
Both of these software types, while created using different languages and frameworks, share the concept of implementing each step in a container, thereby improving horizontal scaling and reusability.
Other famous implementations of CI/CD capabilities include Travis, GitLab, and, more recently, some cloud alternatives such as GitHub Actions.
As we have said, regardless of the implementation, the CI/CD process can automate steps up to the production deployment. However, in order to make the software available to final users, the process requires some final steps.
Releasing is usually the final step of a complete CI/CD pipeline. The process can be performed, in simple environments, together with deployment in production.
However, nowadays, it's common to split deployment and releasing into two different steps, and this allows more sophisticated (and often safer) ways of releasing software versions to end users. To do so, the most basic ingredient is to have different versions of the software available in production at the same time and to route users to each version by following different criteria (which is done by operating at a network level, routing each request to the desired target version). Let's look at some scenarios opened by this kind of technique:
In the preceding diagram, each circle represents an instance (such as a VM or an application instance). The two steps are represented as a sample, but you can imagine that the New version starts from an instance, goes to two, and so on, and the Old version is progressively shut down, one instance at a time.
While this can be a subtle difference, compared to the blue-green deployment, a rolling release can be seen as a kind of technical trick aiming to reduce downtime in releases, but it provides fewer guarantees if a rollback is needed. Moreover, since old and new instances coexist for a certain amount of time, issues may occur, both on the application and on the external systems (such as databases).
In the preceding diagram, we can see just two sample phases, where we start routing 90% of the traffic toward the Old version, and 10% to the New version, and another one representing 1% toward the Old version and 99% toward the New version. Of course, in a real situation, you can imagine a constant flow going from 1% to 100% and vice versa. The name canary refers to the canary used by miners to identify gas leakages. In a similar way, bugs are identified sooner, by a small percentage of users, and it's possible to stop the release before impacting more users.
Such variants are usually small changes in the user interface or other features, and the business aims to measure how those changes perform in the real world. The most common example is with online shops, where the business is willing to check whether changes in the purchase process (colors, position of the buttons, and the number of steps) enhance commercial performance.
In A/B testing, the less performant version is usually discarded. It's also worth noticing that the users routed to each version can be chosen randomly, split by percentage (such as 50% for each version), or even selected by specific criteria (where available), such as the geographical location or the age of the user.
It can be really useful for load testing in the case of major releases, but it may not work in some specific scenarios, and it's necessary to understand the impacts in any specific use case. As an example, if notifications (such as via email) are sent, we should ensure that they are not sent by both the old and the new systems, to minimize the impact on users.
It's worth noticing that these kinds of release techniques (especially the simpler ones) are nothing new, and were also possible before modern cloud and microservice architectures. However, to use them in traditional environments, you may need to coordinate with external teams (such as the ones administering the network), while modern architectures (such as the ones based on public clouds, IaaS or PaaS) provide way more flexibility, allowing the creation of new instances on the fly, and changing network configurations favored by software-defined networking. In the next section, we are going to complete our view of the software life cycle by looking at some considerations regarding software maintenance.
Software maintenance is usually a great part of software life. In my professional experience, it's not uncommon for a project to be fully active (with a lot of new features and developments happening) for a couple of years, followed by many years of maintenance, which is focused on fixing bugs and keeping the product alive (without releasing any new features).
It goes without saying that the maintenance period can become more expensive than the building of the project. Moreover, but this is a consideration purely from an economic perspective, enterprises often find it easier to access the budget for building new applications (which is seen as money generating, or at least associated with business initiatives) than for maintaining and modernizing older ones (which is seen as IT for IT, which means that this is a project with no business impact, hence, purely a cost).
With that said, maintenance activities on existing applications can be roughly categorized into one of the following buckets:
Of course, both of these types of activities can then have an impact and an effort associated, which may help prioritize the implementation.
The software maintenance process is then further categorized into four types, independent of the previous two categories:
Other than having a direct cost (because somebody needs to do it), all the maintenance categories may have several impacts. Indeed, such activities often involve software releases or configuration changes, and this may impact the application availability (and have a negative influence on the Service Level Agreement (SLA) established). There may even be legal agreements relegating maintenance activities to well-defined timeframes or allowing it only to solve high-severity issues.
Moreover, software maintenance can have an indirect impact because the activities (both enhancements and bug fixing) can change the application behavior and, in some cases, even leave the API exposed, hence, forcing the users to adapt to such changes or the developers to plan for implementing the retro compatibility.
With that said, application maintenance should never be neglected. Instead, it should be planned (and financed) from the very beginning of the project and be constantly adjusted over time. Indeed, a project that is not correctly maintained can incur security issues or customer dissatisfaction, or also simply lose attractivity on the market. This will risk nullifying all the efforts made to design and implement the project.
This was the last topic of this chapter. Let's now have a look at a summary of what we have learned.
In this chapter, we have had an overview of many crucial phases of the software life cycle.
Starting with SCM, we had a quick discussion of Git, which is the de facto standard over SCM. This allowed us to understand development models, with a focus on trunk-based development, which is common in CI/CD and DevOps-based projects.
We also briefly discussed semantic versioning, which is a way to identify what changes to expect based on the release version numbering.
We then entered the testing phase, starting with unit testing (and the inevitable description of JUnit, a standard for testing in Java). From unit testing, we moved on to other testing techniques, including integration, end-to-end, and UAT.
Deploying was the next step. We discussed the steps needed to compile software and run it, including an overview of Apache Maven and artifact management. The next topic was CI/CD and pipelines with some consideration around automating most of the steps seen in this chapter.
Then, we focused on releasing, which is the process of making the deployed software available to final users, and we saw many different options to do so, including blue-green and canary releases. Last but not least, we had an overview of maintenance activities (both bug fixes and enhancements) and why they are crucial for the overall success of our project.
In the next chapter, we are going to discuss monitoring and tracing, which are some core concepts for ensuring that our software is performing well in production, and for constantly understanding and governing what's happening.
3.18.220.243