Working Effectively with Legacy Code by Michael C. Feathers

The techniques from this book help me in my work, so I decided to make a summary of it. The examples in it are written in Java and C++, so I could not translate everything to JS. I tried to pull out the most important things, but I advise you to read the book yourself.

Feathers defines legacy code as code without tests. In such code it is impossible to predict whether some change will make this code better or worse.

Chapter 1. Software Changes

The code must be changed in order to:

  • add a feature;
  • fix a bug;
  • improve code design;
  • optimize the use of resources.

The most important thing in a program is its behavior. Users like it when we add new behavior, and they don’t like it when we change the old. When we add new behavior, we inevitably change the old behavior.

// Before change:
class Player {
	addPlaylist(name, tracks) {
		// ...
	}
}

// After:
class Player {
	addPlaylist(name, tracks) {
		// ...
	}

	deletePlaylist(name) {
		// ...
	}
}

As long as the new method is not called anywhere, the behavior does not change. To add this behavior, we display a button on the screen. This causes the interface to take a fraction of a second longer to render. This is imperceptible, but the behavior has changed.

Design improvements, refactoring, are different in that they don’t change the behavior of the program. Their goal is to make the code more readable, maintainable, and pleasing.

Optimization is similar to refactoring, only it is resource usage we are refactoring, not the code itself.

Keeping the behavior unchanged is difficult. Every code change carries the risk of changing behavior. To mitigate this risk, you must ask yourself three questions before making changes:

  • What should we change?
  • How do we know that we made the change correctly?
  • How do we know that we haven’t broken the rest?

The difference between good and bad software systems is that making changes to good ones is not worrisome. In bad systems, the longer you procrastinate, the scarier it is to change in the future.

Chapter 2. Working on Feedback

There are two ways to make changes to the code: “run and pray” and “cover with tests and change”. The point of the second approach is to get feedback constantly and as quickly as possible. Tests give feedback by telling us how our changes break the behavior of the program.

A good unit test is:

  • fast;
  • helps you find the problem quickly;
  • has no external dependencies.

Refactoring legacy code should start by covering it with tests. The problem is that legacy often has a bunch of dependencies that make it hard to test. A dilemma arises: to change the code, you must have tests for it, and to write tests for it, you must change it.

Algorithm for changing legacy code:

  • find change points;
  • find the test points;
  • break dependencies;
  • write tests;
  • make changes and refactor.

Chapter 3. Recognition and Separation

For testing, we need to break dependencies in the code for two reasons:

  • if we can’t access the values the code calculates;
  • if we can’t insert the right fragment of code to run in the test.

The NetworkBridge class gets a list of nodes, each of which opens a network connection and communicates with other nodes:

class NetworkBridge {
	constructor(endpoints) {
		// ...
	}

	formRouting(sourceId, destId) {
		// ...
	}
}

How do we test it? If it is hardware-related, can we afford to load hardware for every test? Can we create a test cluster? Do we have the resources and time to do it? Such problems arise when we do not understand how to isolate the right part and test it in isolation. This is where fake objects can help.

Fake objects impersonate a class during testing. For example, we have a class Sale which scans barcodes, and displays messages on the device screen through the class Display:

class Sale {
	constructor(display) {
		this._display = display;
	}

	scan(barcode) {
		// Scanning...
		// ...
		// Displaying the message.
		this._display.showMessage('hello world');
	}
}

class Display {
	showMessage(msg) {
		// ...
	}
}

const display = new Display();
const sale = new Sale(display);

To not depend on specific hardware, we can write a fake class FakeDisplay:

class FakeDisplay {
	// Instead of displaying it on the screen, we will memorize the message.
	// This is a method that imitates the real method of the Display class:
	showMessage(msg) {
		this.lastLine = msg;
	}

	// And then output it on demand.
	// This is an additional method, which is needed exactly in tests:
	getLastLine() {
		return this.lastLine;
	}
}

In the test, we can substitute a class that works with a particular piece of equipment with a fake one:

it('Displays the product name on the screen', () => {
	const fakeDisplay = new FakeDisplay();
	const saleTest = new Sale(fakeDisplay);

	saleTest.scan('1');

	expect(fakeDisplay.getLastLine).toEqual('Milk');
});

This test won’t fail if some part in the real Display class doesn’t work. But we’re testing the Sale class, not Display, so it doesn’t matter in this particular test.

Chapter 4. Seams

A seam is a place in a program where you can change its behavior without editing the code in that place. The working code must be the same in production and in tests. Seams help to break dependencies and test the code without changing it.

To use a seam, you need to decide on a resolution point—the place where you decide to replace one behavior with another.

It’s most convenient in object-oriented languages to use object seams, where the action of some method is substituted for another. For example, when you create an instance of a class in the constructor.

Chapter 5. Automated Refactoring Tools

Refactoring is improving code quality without changing behavior. There are development environments that offer automatic refactoring tools. They help to rename variables, remove unnecessary things, put code into separate functions or classes.

Programmers often rely on them and do not write tests for the code they are going to refactor with these tools. But this way you can miss changes of behavior. For example:

// Before refactoring:
class Example {
	alpha = 0;

	getValue() {
		this.alpha++;
		return 42;
	}

	doSomething() {
		let total = 0;
		const val = this.getValue();
		for (let i = 0; i < 5; i++) {
			total += val;
		}
	}
}

// After:
class Example {
	alpha = 0;

	getValue() {
		this.alpha++;
		return 42;
	}

	doSomething() {
		let total = 0;
		for (let i = 0; i < 5; i++) {
			total += this.getValue();
		}
	}
}

The extra variable disappeared, but with it alpha++ was called 5 times instead of 1. The unit tests will help to detect this change.

In the Next Post

We’ll talk about changing code when you don’t have enough time, adding features, TDD and dependencies.

Resources