API-Centric Development in Action
Let us see how API-centric development in concert with design thinking may efficiently eradicate expensive interest rates on unwanted technical debts. This occurs anytime a debt is causing harm to clients. The article by spirit follows the ideas from this blog. To make the elaboration more interesting, I’ve decided to start off with an available source code on the Web. It counts the number of primes using the algorithm known as Sieve of Eratosthenes. Here is the full listing for the sake of completeness:
/*
* Sieve.java
*
* Alternate version of Sieve of Eratosthenes.
* Written by John M. Hunt (John.Hunt@covenant.edu).
*
* Keeps an array of booleans indicating if each value is a prime.
*/
public class Sieve {
public static void printSieve(boolean a[]) {
for (int i = 2; i < a.length; i++) {
if (a[i] == true) {
System.out.print(i + ", ");
}
}
}
public static int countSieve(boolean a[]) {
int count = 0;
for (int i = 2; i < a.length; i++) {
if (a[i] == true) {
count++;
}
}
return count;
}
/*
* For each identified prime (true value), cross out all of its multiples
*/
public static void runSieve(boolean a[]) {
for (int i = 2; i < a.length; i++) {
if (a[i] == true) {
for (int n = i + i; n < a.length; n = n + i) {
a[n] = false;
}
}
}
}
public static boolean[] initSieve(int size) {
boolean nums[] = new boolean[size];
for (int i = 0; i < nums.length; i++) {
nums[i] = true;
}
return nums;
}
public static void main(String args[]) {
long startMillis = System.currentTimeMillis();
int size = 8000000;
boolean a[] = initSieve(size);
runSieve(a);
System.out.println("number of primes = " + countSieve(a));
// printSieve(a);
long endMillis = System.currentTimeMillis();
double durationSecs = ((double) (endMillis - startMillis)) / 1000d;
System.out.println("elapsed time in seconds: " + durationSecs);
}
}
The code is reminiscent of a classical legacy system:
It has no automated tests (there is only a rudimentary smoke test inside the main method).
It comes without any documentation of the API; there are only a few ordinary comments.
The public API is convoluted, since it is the client’s responsibility to know the interdependencies between method invocations. To get a count of primers the correct sequence of calls is initSieve -> runSieve -> countSieve with an optional printSieve step to print them out. Furthermore, the client must remember to pass the same array instance to these methods, which was created and returned from the initialization method.
A potential client is forced on strong coupling to the Sieve class.
There is a commented out code inside the main method, another thing to prune away. Notice that such code is useless. If main is supposed to be used as a quick smoke test, then nobody will have access to the source code anyhow from an execution environment (dev, qa, staging, or production).
Phase 1
First we need to fix the public API, as this will give us enough wiggle room to make subsequent restructurings behind the interface. In the absence of an API-centric development, with clients attached to our class directly, there is literally no way to make modifications that preserve compatibility. This is the situation we would like to eschew. We want to turn our technical debt with high interest rates into a form where only we are impacted. We cannot just use the Extract Interface refactoring, since the baseline is improper. We need to craft a brand new user-friendly API.
We also want to equip our class with automated tests. This will enable us to enter phase 2 with work done on cleanup up the “server” side. Without a battery of tests there is a high risk of breaking functionality. This is an equally bad position.
Finally, we want to signal users what methods should be discouraged (deprecated) and give them enough transition time. For the sake of simplicity, I will not develop a separate abstract factory for creating objects, but this should be the norm for professional code. Such a factory may return back a different implementation of the same interface based upon user provided criteria.
Below is the listing of the new API with all operations documented (again, it may look strange, but this API would really shine after being accompanied with a proper factory). Pay special attention to how the printPrimes is documented. The output format is not overspecified. Developers do make errors here, and introduce debt, by needlessly revealing early implementation decisions. The public documentation is an integral part of an API, so must be crafted with care. We will later see that the current printing of primes is far from beautiful, hence it is going to be improved. Of course, you may alter this API and include other operations (like, retrieving primes in a collection), too. But here it is kept simple for the sake of brevity as well as to keep it aligned with our legacy artifact.
/**
* API for a prime finder instance that returns the number of primes in the
* given interval and prints them out. Objects are supposed to be created via
* the matching factory.
*/
public interface PrimeFinder {
/**
* Returns the primes found by this instance for the given interval [3, n].
*
* @return the string printout of primes.
* @see #getRightEnd()
*/
String printPrimes();
/**
* Returns the number of primes found by this instance for the given interval.
*
* @return the number of prime numbers in the interval [3, n].
* @see #getRightEnd()
*/
int getNumberOfPrimes();
/**
* Returns back the right side of the search interval.
*
* @return the right end of the search interval.
*/
int getRightEnd();
}
Next let us see how our newly refactored class looks like:
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
public class Sieve implements PrimeFinder {
private static final int DEFAULT_UPPER_BOUND = 8_000_000;
@Deprecated
public static void printSieve(boolean a[]) {
for (int i = 2; i < a.length; i++) {
if (a[i] == true) {
System.out.print(i + ", ");
}
}
}
@Deprecated
public static int countSieve(boolean a[]) {
int count = 0;
for (int i = 2; i < a.length; i++) {
if (a[i] == true) {
count++;
}
}
return count;
}
@Deprecated
public static void runSieve(boolean a[]) {
for (int i = 2; i < a.length; i++) {
if (a[i] == true) {
for (int n = i + i; n < a.length; n = n + i) {
a[n] = false;
}
}
}
}
@Deprecated
public static boolean[] initSieve(int size) {
boolean nums[] = new boolean[size];
for (int i = 0; i < nums.length; i++) {
nums[i] = true;
}
return nums;
}
private final boolean[] primes;
/**
* Creates an instance of this class holding prime numbers in the given
* interval.
*
* @param n the right side of the closed interval [3, n].
* @throws IllegalArgumentException if n is less than 3.
*/
public Sieve2(int n) {
if (n < 3)
throw new IllegalArgumentException(
"The search interval is [3, n].");
right = n;
primes = initSieve((n & 1) == 1 ? n + 1 : n);
runSieve(primes);
}
@Override
public String printPrimes() {
var oldSystemOut = System.out;
try {
var outputBuffer = new ByteArrayOutputStream();
var newSystemOut = new PrintStream(outputBuffer);
System.setOut(newSystemOut);
printSieve(primes);
newSystemOut.close();
return outputBuffer.toString();
} finally {
System.setOut(oldSystemOut);
}
}
@Override
public int getNumberOfPrimes() {
return countSieve(primes);
}
@Override
public int getRightEnd() {
return right;
}
public static void main(String args[]) {
long startMillis = System.currentTimeMillis();
var sieve = new Sieve(DEFAULT_UPPER_BOUND);
System.out.println("Number of primes = " + sieve.getNumberOfPrimes());
long endMillis = System.currentTimeMillis();
double durationSecs = ((double) (endMillis - startMillis)) / 1000d;
System.out.println("Elapsed time in seconds: " + durationSecs);
}
}
The new implementation has kept the previous API but marked old methods as deprecated. The newly added methods are fully implemented in terms of the existing code. The only major rework was inside the printing facility, as we needed to capture the standard output to faithfully mimic the old behavior. The main method has been cleaned up to use the PrimeFinder interface (we will further improve it in the next stage). By working with instances instead of calling procedural static methods it is possible to pass primes to other parts of a system without exposing internal implementation details (like the previous boolean array). Furthermore, objects returned to us are already fully initialized, so we may call all public methods in any order we like. Watch out that we are obligated to provide and document the public constructor until we introduce a special factory (in this case the constructor should be designated as private).
Now we are left with producing the automated tests. It is extremely important to keep unit tests Agile. Otherwise, we would introduce another technical debt, this time related to our inability to make progress without altering a ton of unit tests in parallel. Tests should never hinder the pace of working on the production code. So, it is wise to test only publicly announced behavior through an API. The tests are presented below (they use the JUnit 5.4 framework) :
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class SieveTest {
@ParameterizedTest
@MethodSource("createSieveExamples")
void countPrimes(int expected, int example) {
assertEquals(expected, new Sieve(example).getNumberOfPrimes());
}
private static Stream<Arguments> createSieveExamples() {
return Stream.of(
Arguments.of(2, 3),
Arguments.of(2, 4),
Arguments.of(4, 10),
Arguments.of(25, 100),
Arguments.of(539_777, 8_000_000)
);
}
@Test
void retrieveRightSideOfInterval() {
assertEquals(7, new Sieve2(7).getRightEnd());
}
@Test
void printPrimes() {
assertEquals("2, 3, 5, 7, ", new Sieve2(10).printPrimes());
}
}
Observe the usage of parameterized tests as a testimony that the DRY principle applies here, too. Tests should be written with great care, as test tools may produce rich reports based on them. Look at the printPrimes test to understand why the current printout is clumsy. Thanks to our cautious documentation we are totally free to fix this in the next release.
As a side note, keep the number of assertions at minimum. In case there is a need to verify a complex object, instead of asserting individual fields, you can rely on the novel approval tests approach.
Phase 2
We are now ready to clean things up and also make various enhancements including performance optimization. None of these would impact new clients. Below is the final version of our prime finder:
import java.util.BitSet;
public class Sieve implements PrimeFinder {
private static final int DEFAULT_UPPER_BOUND = 8_000_000;
private final BitSet sieve;
private final int upperBound;
private final int right;
public Sieve(int n) {
if (n < 3)
throw new IllegalArgumentException(
"The search interval is [3, n].");
right = n;
if ((n & 1) == 1)
n++;
sieve = new BitSet(n);
sieve.set(2, n);
upperBound = (int) Math.ceil(Math.sqrt(n));
findPrimes();
}
@Override
public String printPrimes() {
return sieve.toString();
}
@Override
public int getNumberOfPrimes() {
return sieve.cardinality();
}
@Override
public int getRightEnd() {
return right;
}
private void findPrimes() {
for (int i = 2; i <= upperBound; i++)
if (sieve.get(i))
for (int j = i * i; j < sieve.length(); j += i)
sieve.clear(j);
}
public static void main(String args[]) {
int size = DEFAULT_UPPER_BOUND;
if (args.length == 1)
size = Integer.parseInt(args[0]); // Assume size is >= 2.
PrimeFinder sieve = new Sieve(size);
System.out.println("Number of primes = " + sieve.getNumberOfPrimes());
if (size <= 100)
sieve.printPrimes();
}
}
The only test that needs to be corrected is the printout test, since our output is now {2, 3, 4, 5, 7} for the corresponding test case. We are now using the built-in BitSet class to store primes. Finally, the main method is more useful than before, with a possibility to pass on a command line the right end of the search interval.
What we have done here illuminates the essential idea behind the Branch by Abstraction pattern for restructuring legacy systems. Gradually improving an existing system by taking advantage of APIs avoids creating forks in a repository as well as protects clients from frequent disturbances.
Conclusion
As you have witnessed, one way to tackle and prevent accumulation of technical debt is to incorporate quality assurance. This is not about testing, which primarily sits inside a quality control realm. Rather it is about proactive steps for preventing bugs from sneaking into a product. Having an explicit API may reduce accidental complexity; it is a container for all sorts of distractions that are not related to the essential complexity of the problem space itself. A well documented API lowers the probability of a risk of making a bug due to inconsistencies in a code base. By establishing common expectations in the form of conventions, people wouldn’t struggle with unnecessary context switches due to various styles. In other words, there is a less chance of missing pertinent details. For example, SwaggerHub Enterprise offers a feature called API Standardization, that lets the system check if REST APIs are structured in common fashion (as defined by an organization). Another good example of minimizing accidental complexity is by establishing a standard way of expressing design models. Here, UML together with tools like PlantUML could be a solution.
Quality and management of technical debt is a huge topic and this article just gave some major directions where to look further. The embedded links toward external material should be heralded as integral parts of this paper. The examples tried to illustrate the way principles play a crucial role in setting higher norms. SAFe, or any other Agile method, should be applied in concerted fashion alongside rising the overall culture. These are the primary mechanisms in combating unwanted technical debt.