
Chapter 3. How DevOps Affects Architecture
Software architecture is a vast subject, and in this book, we will focus on the aspects of architecture that have the largest effects on Continuous Delivery and DevOps and vice versa.
In this chapter, we will see:
- Aspects of software architecture and what it means to us while working with our DevOps glasses on
- Basic terminology and goals
- Anti-patterns, such as the monolith
- The fundamental principle of the separation of concerns
- Three-tier systems and microservices
We finally conclude with some practical issues regarding database migration.
It's quite a handful, so let's get started!
Introducing software architecture
We will discuss how DevOps affects the architecture of our applications rather than the architecture of software deployment systems, which we discuss elsewhere in the book.
Often while discussing software architecture, we think of the non-functional requirements of our software. By non-functional requirements, we mean different characteristics of the software rather than the requirements on particular behaviors.
A functional requirement could be that our system should be able to deal with credit card transactions. A non-functional requirement could be that the system should be able to manage several such credit cards transactions per second.
Here are two of the non-functional requirements that DevOps and Continuous Delivery place on software architecture:
- We need to be able to deploy small changes often
- We need to be able to have great confidence in the quality of our changes
The normal case should be that we are able to deploy small changes all the way from developers' machines to production in a small amount of time. Rolling back a change because of unexpected problems caused by it should be a rare occurrence.
So, if we take out the deployment systems from the equation for a while, how will the architecture of the software systems we deploy be affected?
The monolithic scenario
One way to understand the issues that a problematic architecture can cause for Continuous Delivery is to consider a counterexample for a while.
Let's suppose we have a large web application with many different functions. We also have a static website inside the application. The entire web application is deployed as a single Java EE application archive. So, when we need to fix a spelling mistake in the static website, we need to rebuild the entire web application archive and deploy it again.
While this might be seen as a silly example, and the enlightened reader would never do such a thing, I have seen this anti-pattern live in the real world. As DevOps engineers, this could be an actual situation that we might be asked to solve.
Let's break down the consequences of this tangling of concerns. What happens when we want to correct a spelling mistake? Let's take a look:
- We know which spelling mistake we want to correct, but in which code base do we need to do it? Since we have a monolith, we need to make a branch in our code base's revision control system. This new branch corresponds to the code that we have running in production.
- Make the branch and correct the spelling mistake.
- Build a new artifact with the correction. Give it a new version.
- Deploy the new artifact to production.
Okay, this doesn't seem altogether too bad at first glance. But consider the following too:
- We made a change in the monolith that our entire business critical system comprises. If something breaks while we are deploying the new version, we lose revenue by the minute. Are we really sure that the change affects nothing else?
- It turns out that we didn't really just limit the change to correcting a spelling mistake. We also changed a number of version strings when we produced the new artifact. But changing a version string should be safe too, right? Are we sure?
The point here is that we have already spent considerable mental energy in making sure that the change is really safe. The system is so complex that it becomes difficult to think about the effects of changes, even though they might be trivial.
Now, a change is usually more complex than a simple spelling correction. Thus, we need to exercise all aspects of the deployment chain, including manual verification, for all changes to a monolith.
We are now in a place that we would rather not be.
Architecture rules of thumb
There are a number of architecture rules that might help us understand how to deal with the previous undesirable situation.
The separation of concerns
The renowned Dutch computer scientist Edsger Dijkstra first mentioned his idea of how to organize thought efficiently in his paper from 1974, On the role of scientific thought.
He called this idea "the separation of concerns". To this date, it is arguably the single most important rule in software design. There are many other well-known rules, but many of them follow from the idea of the separation of concerns. The fundamental principle is simply that we should consider different aspects of a system separately.
The principle of cohesion
In computer science, cohesion refers to the degree to which the elements of a software module belong together.
Cohesion can be used as a measure of how strongly related the functions in a module are.
It is desirable to have strong cohesion in a module.
We can see that strong cohesion is another aspect of the principle of the separation of concerns.
Coupling
Coupling refers to the degree of dependency between two modules. We always want low coupling between modules.
Again, we can see coupling as another aspect of the principle of the separation of concerns.
Systems with high cohesion and low coupling would automatically have separation of concerns, and vice versa.
Back to the monolithic scenario
In the previous scenario with the spelling correction, it is clear that we failed with respect to the separation of concerns. We didn't have any modularization at all, at least from a deployment point of view. The system appears to have the undesirable features of low cohesion and high coupling.
If we had a set of separate deployment modules instead, our spelling correction would most likely have affected only a single module. It would have been more apparent that deploying the change was safe.
How this should be accomplished in practice varies, of course. In this particular example, the spelling corrections probably belong to a frontend web component. At the very least, this frontend component can be deployed separately from the backend components and have their own life cycle.
In the real world though, we might not be lucky enough to always be able to influence the different technologies used by the organization where we work. The frontend might, for instance, be implemented using a proprietary content management system with quirks of its own. Where you experience such circumstances, it would be wise to keep track of the cost such a system causes.
A practical example
Let's now take a look at the concrete example we will be working on for the remainder of the book. In our example, we work for an organization called Matangle. This organization is a software as a service (SaaS) provider that sells access to educational games for schoolchildren.
As with any such provider, we will, with all likelihood, have a database containing customer information. It is this database that we will start out with.
The organization's other systems will be fleshed out as we go along, but this initial system serves well for our purposes.
Three-tier systems
The Matangle customer database is a very typical three-tier, CRUD (create, read, update, and delete) type of system. This software architecture pattern has been in use for decades and continues to be popular. These types of systems are very common, and it is quite likely that you will encounter them, either as legacy systems or as new designs.
In this figure, we can see the separation of concerns idea in action:

The three tiers listed as follows show examples of how our organization has chosen to build its system.
The presentation tier
The presentation tier will be a web frontend implemented using the React web framework. It will be deployed as a set of JavaScript and static HTML files. The React framework is fairly recent. Your organization might not use React but perhaps some other framework such as Angular instead. In any case, from a deployment and build point of view, most JavaScript frameworks are similar.
The logic tier
The logic tier is a backend implemented using the Clojure language on the Java platform. The Java platform is very common in large organizations, while smaller organizations might prefer other platforms based on Ruby or Python. Our example, based on Clojure, contains a little bit of both worlds.
The data tier
In our case, the database is implemented with the PostgreSQL database system. PostgreSQL is a relational database management system. While arguably not as common as MySQL installations, larger enterprises might prefer Oracle databases. PostgreSQL is, in any case, a robust system, and our example organization has chosen PostgreSQL for this reason.
From a DevOps point of view, the three-tier pattern looks compelling, at least superficially. It should be possible to deploy changes to each of the three layers separately, which would make it simple to propagate small changes through the servers.
Tip
In practice, though, the data tier and logic tier are often tightly coupled. The same might also be true for the presentation tier and logic tier. To avoid this, care must be taken to keep the interfaces between the tiers lean. Using well-known patterns isn't necessarily a panacea. If we don't take care while designing our system, we can still wind up with an undesirable monolithic system.
Handling database migrations
Handling changes in a relational database requires special consideration.
A relational database stores both data and the structure of the data. Upgrading a database schema offers other challenges then the ones present when upgrading program binaries. Usually, when we upgrade an application binary, we stop the application, upgrade it, and then start it again. We don't really bother about the application state. That is handled outside of the application.
When upgrading databases, we do need to consider state, because a database contains comparatively little logic and structure, but contains much state.
In order to describe a database structure change, we issue a command to change the structure.
The database structures before and after a change is applied should be seen as different versions of the database. How do we keep track of database versions?
Note
Liquibase is a database migration system that, at its core, uses a tried and tested method. There are many systems like this, usually at least one for every programming language. Liquibase is well-known in the Java world, and even in the Java world, there are several alternatives that work in a similar manner. Flyway is another example for the Java platform.
Generally, database migration systems employ some variant of the following method:
- Add a table to the database where a database version is stored.
- Keep track of database change commands and bunch them together in versioned changesets. In the case of Liquibase, these changes are stored in XML files. Flyway employs a slightly different method where the changesets are handled as separate SQL files or occasionally as separate Java classes for more complex transitions.
- When Liquibase is being asked to upgrade a database, it looks at the metadata table and determines which changesets to run in order to make the database up-to-date with the latest version.
As previously stated, many database version management systems work like this. They differ mostly in the way the changesets are stored and how they determine which changesets to run. They might be stored in an XML file, like in the case of Liquibase, or as a set of separate SQL files, as with Flyway. This later method is more common with homegrown systems and has some advantages. The Clojure ecosystem also has at least one similar database migration system of its own, called Migratus.
Rolling upgrades
Another thing to consider when doing database migrations is what can be referred to as rolling upgrades. These kinds of deployments are common when you don't want your end user to experience any downtime, or at least very little downtime.
Here is an example of a rolling upgrade for our organization's customer database.
When we start, we have a running system with one database and two servers. We have a load balancer in front of the two servers.
We are going to roll out a change to the database schema, which also affects the servers. We are going to split the customer name field in the database into two separate fields, first name and surname.
This is an incompatible change. How do we minimize downtime? Let's look at the solution:
- We start out by doing a database migration that creates the two new name fields and then fills these new fields by taking the old name field and splitting the field into two halves by finding a space character in the name. This was the initial chosen encoding for names, which wasn't stellar. This is why we want to change it.
This change is so far backward compatible, because we didn't remove the name field; we just created two new fields that are, so far, unused.
- Next, we change the load balancer configuration so that the second of our two servers is no longer accessible from the outside world. The first server chugs along happily, because the old name field is still accessible to the old server code.
- Now we are free to upgrade server two, since nobody uses it.
After the upgrade, we start it, and it is also happy because it uses the two new database fields.
- At this point, we can again switch the load balancer configuration such that server one is not available, and server two is brought online instead. We do the same kind of upgrade on server one while it is offline. We start it and now make both servers accessible again by reinstating our original load balancer configuration.
Now, the change is deployed almost completely. The only thing remaining is removing the old name field, since no server code uses it anymore.
As we can see, rolling upgrades require a lot of work in advance to function properly. It is far easier to schedule upgrades during natural downtimes, if your organization has any. International organizations might not have any suitable natural windows to perform upgrades, and then rolling upgrades might be the only option.
Hello world in Liquibase
This is a simple "hello world" style example for the Liquibase relational database changeset handler.
To try the example out, unpack the source code bundle and run Maven, the Java build tool.
cd ch3-liquibase-helloworld mvn liquibase:update
The changelog file
The following is an example of what a Liquibase changelog file can look like.
It defines two changesets, or migrations, with the numerical identifiers 1 and 2:
- Changeset 1 creates a table called customer, with a column called name
- Changeset 2 adds a column called address to the table called customer
<?xml version="1.0" encoding="utf-8"?> <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.0.xsd"> <changeSet id="1" author="jave"> <createTable tableName="customer"> <column name="id" type="int"/> <column name="name" type="varchar(50)"/> </createTable> </changeSet> <changeSet id="2" author="jave"> <addColumn tableName="customer"> <column name="address" type="varchar(255)"/> </addColumn> </changeSet> </databaseChangeLog>
The pom.xml file
The pom.xml
file, which is a standard Maven project model file, defines things such as the JDBC URL we need so that we can connect to the database we wish to work with as well as the version of the Liquibase plugin.
An H2 database file, /tmp/liquidhello.h2.db
, will be created. H2 is a convenient in-memory database suitable for testing.
This is the pom.xml file for the "liquibase hello world" example:
<?xml version="1.0" encoding="utf-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>se.verona.liquibasehello</groupId> <artifactId>liquibasehello</artifactId> <version>1.0-SNAPSHOT</version> <build> <plugins> <plugin> <groupId>org.liquibase</groupId> <artifactId>liquibase-maven-plugin</artifactId> <version>3.0.0-rc1</version> <configuration> <changeLogFile>src/main/resources/db-changelog.xml </changeLogFile> <driver>org.h2.Driver</driver> <url>jdbc:h2:liquidhello</url> </configuration> <dependencies> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <version>1.3.171</version> </dependency> </dependencies> </plugin> </plugins> </build> </project>
If you run the code and everything works correctly, the result will be an H2 database file. H2 has a simple web interface, where you can verify that the database structure is indeed what you expect.
Manual installation
Before we can automate something, we need to understand the corresponding manual process.
Throughout this book, it is assumed that we are using a Red Hat based Linux distribution, such as Fedora or CentOS. Most Linux distributions are similar in principle, except that the command set used for package operations will perhaps differ a bit.
For the exercises, you can either use a physical server or a virtual machine installed in VirtualBox.
First we need the PostgreSQL relational database. Use this command:
dnf install postgresql
This will check whether there is a PostgreSQL server installed already. Otherwise, it will fetch the PostgreSQL packages from a remote yum repository and install it. So, on reflection, many of the potentially manual steps involved are already automated. We don't need to compile the software, check software versions, install dependencies, and so on. All of this is already done in advance on the Fedora project's build servers, which is very convenient.
For our own organization's software though, we will need to eventually emulate this behavior ourselves.
We will similarly also need a web server, in this case, NGINX. To install it, use the following command:
dnf install nginx
The dnf
command replaces yum
in Red Hat derived distributions. It is a compatible rewrite of yum
that keeps the same command line interface.
The software that we are deploying, the Matangle customer relationship database, doesn't technically need a separate database and web server as such. A web server called HTTP Kit is already included within the Clojure layer of the software.
Often, a dedicated web server is used in front of servers built on Java, Python, and so on. The primary reason for this is, again, an issue with the separation of concerns; this time, it is not for the separation of logic but for non-functional requirements such as performance, load balancing, and security. A Java based web server might be perfectly capable of serving static content these days, but a native C-based web server such as Apache httpd
or NGINX still has superior performance and more modest memory requirements. It is also common to use a frontend web server for SSL acceleration or load balancing, for instance.
Now, we have a database and a web server. At this point, we need to build and deploy our organization's application.
On your development machine, perform the following steps in the book's unpacked source archive directory:
cd ch3/crm1 lein build
We have now produced a Java archive that can be used to deploy and run the application.
Try out the application:
lein run
Point a browser to the URL presented in the console to see the web user interface.
How do we deploy the application properly on our servers? It would be nice if we could use the same commands and mechanisms as when we installed our databases and webservers. We will see how we do that in Chapter 7, Deploying the Code. For now, just running the application from a shell will have to suffice.
Microservices
Microservices is a recent term used to describe systems where the logic tier of the three-tier pattern is composed of several smaller services that communicate with language-agnostic protocols.
Typically, the language-agnostic protocol is HTTP based, commonly JSON REST, but this is not mandatory. There are several possibilities for the protocol layer.
This architectural design pattern lends itself well to a Continuous Delivery approach since, as we have seen, it's easier to deploy a set of smaller standalone services than a monolith.
Here is an illustration of what a microservices deployment might look like:

We will evolve our example towards the microservices architecture as we go along.
Interlude – Conway's Law
In 1968, Melvin Conway introduced the idea that the structure of an organization that designs software winds up copied in the organization of the software. This is called Conway's Law.
The three-tier pattern, for instance, mirrors the way many organizations' IT departments are structured:
- The database administrator team, or DBA team for short
- The backend developer team
- The frontend developer team
- The operations team
Well, that makes four teams, but we can see the resemblance clearly between the architectural pattern and the organization.
The primary goal of DevOps is to bring different roles together, preferably in cross-functional teams. If Conway's Law holds true, the organization of such teams would be mirrored in their designs.
The microservice pattern happens to mirror a cross-functional team quite closely.
How to keep service interfaces forward compatible
Service interfaces must be allowed to evolve over time. This is natural, since the organization's needs change over time, and service interfaces reflect that to a degree.
How can we accomplish this? One way is to use a pattern that is sometimes called Tolerant Reader. This simply means that the consumer of a service should ignore data that it doesn't recognize.
This is a method that lends itself well to REST implementations.
Note
SOAP, which is an XML schema-based method to define services, is more rigorous. With SOAP, you normally don't change existing interfaces. Interfaces are seen as contracts that should be constant. Instead of changing the interface, you define a new schema version. Existing consumers either have to implement the new protocol and then be deployed again, or the producer needs to provide several versioned endpoints in parallel. This is cumbersome and creates undesirable tighter coupling between providers and consumers.
While the DevOps and Continuous Delivery ideas don't actually do much in the way of mandating how things should be done, the most efficient method is usually favored.
In our example, it can be argued that the least expensive method is to spread the burden of implementing changes over both the producer and consumer. The producer needs to be changed in any case, and the consumer needs to accept a onetime cost of implementing the Tolerant Reader pattern. It is possible to do this with SOAP and XML, but it is less natural than with a REST implementation. This is one of the reasons why REST implementations are more popular in organizations where DevOps and Continuous Delivery have been embraced.
How to implement the Tolerant Reader pattern in practice varies with the platform used. For JsonRest, it's usually sufficient to parse the JSON structure into the equivalent language-specific structure. Then, you pick the parts you need for your application. All other parts, both old and new, are ignored. The limitation of this method is that the producer can't remove the parts of the structure that the consumer relies on. Adding new parts is okay, because they will be ignored.
This again puts a burden on the producer to keep track of the consumer's wishes.
Inside the walls of an organization, this isn't necessarily a big problem. The producer can keep track of copies of the consumer's latest reader code and test that they still work during the producer's build phase.
For services that are exposed to the Internet at large, this method isn't really usable, in which case the more rigorous approach of SOAP is preferable.
Microservices and the data tier
One way of viewing microservices is that each microservice is potentially a separate three-tier system. We don't normally implement each tier for each microservice though. With this in mind, we see that each microservice can implement its own data layer. The benefit would be a potential increase of separation between services.
Tip
It is more common in my experience, though, to put all of the organization's data into a single database or at least a single database type. This is more common, but not necessarily better.
There are pros and cons to both scenarios. It is easier to deploy changes when the systems are clearly separate from each other. On the other hand, data modeling is easier when everything is stored in the same database.
DevOps, architecture, and resilience
We have seen that the microservice architecture has many desirable properties from a DevOps point of view. An important goal of DevOps is to place new features in the hands of our user faster. This is a consequence of the greater amount of modularization that microservices provide.
Those who fear that microservices could make life uninteresting by offering a perfect solution without drawbacks can take a sigh of relief, though. Microservices do offer challenges of their own.
We want to be able to deploy new code quickly, but we also want our software to be reliable.
Microservices have more integration points between systems and suffer from a higher possibility of failure than monolithic systems.
Automated testing is very important with DevOps so that the changes we deploy are of good quality and can be relied upon. This is, however, not a solution to the problem of services that suddenly stop working for other reasons. Since we have more running services with the microservice pattern, it is statistically more likely for a service to fail.
We can partially mitigate this problem by making an effort to monitor the services and take appropriate action when something fails. This should preferably be automated.
In our customer database example, we can employ the following strategy:
- We use two application servers that both run our application
- The application offers a special monitoring interface via JsonRest
- A monitoring daemon periodically polls this monitoring interface
- If a server stops working, the load balancer is reconfigured such that the offending server is taken out of the server pool
This is obviously a simple example, but it serves to illustrate the challenges we face when designing resilient systems that comprise many moving parts and how they might affect architectural decisions.
Why do we offer our own application-specific monitoring interface though? Since the purpose of monitoring is to give us a thorough understanding of the current health of the system, we normally monitor many aspects of the application stack. We monitor that the server CPU isn't overloaded, that there is sufficient disk and memory space available, and that the base application server is running. This might not be sufficient to determine whether a service is running properly, though. For instance, the service might for some reason have a broken database access configuration. A service-specific health probing interface would attempt to contact the database and return the status of the connection in the return structure.
It is, of course, best if your organization can agree on a common format for the probing return structure. The structure will also depend on the type of monitoring software used.
Summary
In this chapter, we took a look at the vast subject of software architecture from the viewpoint of DevOps and Continuous Delivery.
We learned about the many different faces that the rule of the separation of concerns can take. We also started working on the deployment strategy for one component within our organization, the Matangle customer database.
We delved into details, such as how to install software from repositories and how to manage database changes. We also had a look at high level subjects such as classic three-tier systems and the more recent microservices concept.
Our next stop will deal with how to manage source code and set up a source code revision control system.