Andy Macdonald

@andymacdroo

Practical Tips for How to Survive and Thrive in the Chaos of Legacy Software

While we’d all love to be working on new technologies, nice clean code and have a generally easy life all of the time — this can’t always be the case.

At some point in our careers we have to lift the veil of a legacy software system and gaze at what is beneath…

“Oh wow… Struts 1, SOAP and ANT with no dependency management… Beautiful… 🤮”

What can we do to make this somewhat undesirable situation a little more tolerable?

Docker… Dockerise All Of The Things

Docker is a tool designed to make it easier to create, deploy, and run applications by using containers. Containers allow developers to package applications with all of the parts it needs, such as libraries and other dependencies, and ship it all out as one package.

Incorporating Docker into a project should become like a reflex and I’d recommend is the first thing you do — before you even write any actual code.

An Example: You need to maintain an application which runs on an outdated application server, has no tests and no dependency management. You need to share this software environment with other’s in the team but installing the dependencies is a considerable effort.

A Solution: Write a Dockerfile for deploying the application and its dependencies. Create a Docker container / image for anything else pertinent to running the environment (e.g. a development database) and use something like Docker-Compose for orchestrating the containers in your multi-container development environment.

Benefits:

  • You can now easily share the software environment with others in the team and they can get up and running working with the application relatively quickly.
  • The environment is documented by Dockerfile in case it needs to be reproduced elsewhere.
  • Acceptance tests can be written against the Docker container and a CI pipeline can be created for the application without the CI server requiring any knowledge of the software environment in which the application runs.
  • You have opened up an option for containerised deployment of the application in production.

Here’s a toy example from a project I’ve worked on in the past. The project used GlassFish as an application server, Ant to build the project and had no dependency management or unit tests:

Dockerfile:

FROM glassfish:latest
LABEL maintainer="andymacdroo"
COPY dist/App.war /App.war
COPY lib/mysql-connector*.jar /usr/local/glassfish4/glassfish/domains/domain1/lib/ext/
COPY scripts/configure-glassfish-datasource.sh /configure-glassfish-datasource.sh
COPY scripts/deploy-war.sh /deploy-war.sh
RUN /configure-glassfish-datasource.sh
EXPOSE 4848 8080 8181
# Start asadmin console and the domain
CMD ["/deploy-war.sh"]

And to build, simply run in the project’s directory:
docker build -t andymacdroo/app .

deploy-war.sh:

#!/bin/sh
asadmin start-domain
asadmin -u admin deploy /App.war
asadmin stop-domain
asadmin start-domain -v

configure-glassfish-datasource.sh:

#!/bin/sh
asadmin start-domain
asadmin create-jdbc-connection-pool --user admin --restype javax.sql.DataSource --datasourceclassname com.mysql.jdbc.jdbc2.optional.MysqlDataSource --property "user=root:password=root:url=jdbc\\:mysql\\://mysql\\:3306/myDB" myDB;
asadmin create-jdbc-resource --user admin --connectionpoolid myDB jdbc/myDB
asadmin stop-domain

docker-compose.yml

version: '3'
services:
mysql:
image: mariadb:10.3
container_name: mariadb
volumes:
- container-volume:/var/lib/mysql
- ./data/dump.sql:/docker-entrypoint-initdb.d/dump.sql
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: myDB
ports:
- "3306:3306"
myApp:
image: andymacdroo/app:latest
container_name: app
ports:
- "4848:4848"
- "8082:8080"
- "8083:8081"
links:
- mysql
volumes:
container-volume:

With this setup, the environment can be easily ported to others in the team and is completely disposable (you also don’t need to dirty your own development machine with obsolete software 🤢).

The Adapter Pattern

Do you actually need to write any code within the legacy project? Can you avoid doing so altogether?

It could be helpful to consider the current legacy code or application as a 3rd party that you can’t control or change. What could you do?

In many situations you can leverage the adapter design pattern — taking legacy software output and converting it into a form that can be consumed by a new application or a system that you’re migrating to.

An Example: You need to migrate a legacy daemon process which takes data from a queue and makes API calls, to make those API calls to another 3rd party API.

  • The code has no unit tests of any kind, is very difficult to read and has lots of embedded business logic and branching.
  • The request body between the old system and the new 3rd party system are quite different.

A Solution: Write an adapter function to slot in between the legacy daemon process and the new 3rd party API — the job of this adapter is to convert the current request body that gets generated into a form the 3rd party can consume and understand and to forward it to the 3rd party.

You then just need to point the legacy process at the new adapter function and it takes care of the rest.

Be a Brain Surgeon, not a Steam-Roller

It can be really tempting to take a legacy project and begin changing absolutely everything.

But without good tests (as most legacy code seems to be without), what’s to protect you from introducing a defect or changing functionality in a major way?

Write tests that seem to be missing or broken, but if this is impractical, only change what must be changed.

Imagine being a brain surgeon, delicately performing micro-surgery on an extremely complicated organ that no-one fully understands.

You won’t truly understand the pain and anxiety of having to debug a legacy application — after you’ve casually implemented a massive sweeping change and there’s been a critical incident in production, until it’s happened to you.
Trust me, it isn’t a fun experience… 😢

And, if you are writing additional tests…

TDD — Red, Green, Refactor

Image result for test driven
  1. Write a test for a given unit of functionality / feature.
  2. Run the test; ensure your newly added test fails.
  3. Write the minimum amount of code for your test to pass (don’t care about code quality here) — run the test and ensure it passes.
  4. Refactor your code (now care about code quality again!)
    re-run your tests to ensure your code still passes.

The Developer’s “Hippocratic Oath”:

The Boy-Scout Rule

Newly qualified medical doctors typically abide by the pledge / oath:

“Primum non nocere” — “First, do no harm”

For developer’s we should go one step further and follow the Boy Scout Rule:

Boy Scouts believe in leaving the campground cleaner than it was found, developer’s should embrace this with the code they work on

This is for one simple reason: “Broken Window Theory”:

One broken window, if left unrepaired for a substantial amount of time, instills a sense of abandonment. So another window gets broken. People start littering. Graffiti appears. Serious structural damage begins. In a relatively short time, the building becomes damaged beyond the owner’s desire to fix it, and the sense of abandonment becomes reality — James Q. Wilson and George Kelling (1982)

The same situation can happen to any code-base if we cut too many corners and don’t make the effort to maintain projects: avoiding repaying technical debt when opportunities present themselves to do so.

Do you want this to eventually happen to your new projects?

All legacy code and software started life as a new and shiny project at some point.

Treat legacy projects with the same level of TLC as any new and shiny project. Be pragmatic, sometimes we have to be, but don’t make things worse.

Thank you for reading. I hope this article has given you a handful of tips to help you navigate the difficulties of working on legacy applications. 😀

More by Andy Macdonald

Topics of interest

More Related Stories