Halfway on Main: Thoughts on Clean Architecture
Uncle Bob ignores his own advice when considering the "Main" component, but we can improve on his thoughts and learn from them.
Chapter 26 is a short section of Uncle Bob Martin's classic, Clean Architecture. It
discusses the necessary evil of creating a Main component
which handles the dirty work and initializes the rest of the program. This component necessarily
breaks rules to get the show running and provide an interface with the non-clean world which is
our reality. The Main component takes care of setting up
globals and may enter the program into an infinite loop to keep it running forever.
Uncle Bob gives a lengthy yet incomplete example of a
Main class component for a hypothetical game of "Hunt
the Wumpus". The game is a text-based somewhat-roguelite dungeon-crawler in which you seek
out the Wumpus and avoid traps. A simple game concept, well within the wheelhouse of a
first-year computer science student, and Uncle Bob's code looks the part. For an otherwise
insightful book about how to separate concerns, Martin seems to give up when it comes to this
component, relegating it to be "the dirtiest of all the dirty components" without any
effort to find a better way. The example class he presents is needlessly brittle and repetitive.
Here is that entire Main class, as presented in the book.
Note that the book contains the comment at the end (much code removed...), it wasn't added here.
public class Main implements HtwMessageReceiver {
private static HuntTheWumpus game;
private static int hitPoints = 10;
private static final List<String> caverns = new ArrayList<>();
private static final String[] environments = new String[]{
"bright",
"humid",
"dry",
"creepy",
"ugly",
"foggy",
"hot",
"cold",
"drafty",
"dreadful"
};
private static final String[] shapes = new String[] {
"round",
"square",
"oval",
"irregular",
"long",
"craggy",
"rough",
"tall",
"narrow"
};
private static final String[] cavernTypes = new String[] {
"cavern",
"room",
"chamber",
"catacomb",
"crevasse",
"cell",
"tunnel",
"passageway",
"hall",
"expanse"
};
private static final String[] adornments = new String[] {
"smelling of sulfur",
"with engravings on the walls",
"with a bumpy floor",
"",
"littered with garbage",
"spattered with guano",
"with piles of Wumpus droppings",
"with bones scattered around",
"with a corpse on the floor",
"that seems to vibrate",
"that feels stuffy",
"that fills you with dread"
};
public static void main(String[] args) throws IOException {
game = HtwFactory.makeGame("htw.game.HuntTheWumpusFacade", new Main());
createMap();
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
game.makeRestCommand().execute();
while (true) {
System.out.println(game.getPlayerCavern());
System.out.println("Health: " + hitPoints + " arrows: " + game.getQuiver());
HuntTheWumpus.Command c = game.makeRestCommand();
System.out.println(">");
String command = br.readLine();
if (command.equalsIgnoreCase("e"))
c = game.makeMoveCommand(EAST);
else if (command.equalsIgnoreCase("w"))
c = game.makeMoveCommand(WEST);
else if (command.equalsIgnoreCase("n"))
c = game.makeMoveCommand(NORTH);
else if (command.equalsIgnoreCase("s"))
c = game.makeMoveCommand(SOUTH);
else if (command.equalsIgnoreCase("r"))
c = game.makeRestCommand();
else if (command.equalsIgnoreCase("sw"))
c = game.makeShootCommand(WEST);
else if (command.equalsIgnoreCase("se"))
c = game.makeShootCommand(EAST);
else if (command.equalsIgnoreCase("sn"))
c = game.makeShootCommand(NORTH);
else if (command.equalsIgnoreCase("ss"))
c = game.makeShootCommand(SOUTH);
else if (command.equalsIgnoreCase("q"))
return;
c.execute();
}
}
private static void createMap() {
int nCaverns = (int) (Math.random() * 30.0 + 10.0);
while (nCaverns-- > 0)
caverns.add(makeName());
for (String cavern : caverns) {
maybeConnectCavern(cavern, NORTH);
maybeConnectCavern(cavern, SOUTH);
maybeConnectCavern(cavern, EAST);
maybeConnectCavern(cavern, WEST);
}
String playerCavern = anyCavern();
game.setPlayerCavern(playerCavern);
game.setWumpusCavern(anyOther(playerCavern));
game.addBatCavern(anyOther(playerCavern));
game.addBatCavern(anyOther(playerCavern));
game.addBatCavern(anyOther(playerCavern));
game.addPitCavern(anyOther(playerCavern));
game.addPitCavern(anyOther(playerCavern));
game.addPitCavern(anyOther(playerCavern));
game.setQuiver(5);
}
// much code removed...
}
User command parsing
Let's start with the low-hanging fruit: repetitive statements. The
main method contains the primary game loop, which runs
forever until the user enters "q". Most of this is
fine, but the long block of else if's are not
only difficult to read, they're needlessly inefficient. We must test input against every
statement in sequence until one turns up true or there are no statements left to test. Further,
if the user enters a command which matches none of the conditions (necessitating a complete
run-through of them all), the game executes a "rest" command, declared outside the set
of conditions, which could easily come back to bite an unsuspecting developer in the future.
Whenever there is a set of three or more distinct conditions to test, it's almost always a
better bet to use a switch,
case or best of all (if the language supports it), pattern
matching. The game loop cleans up a bit if we use this advice. We can also take advantage of the
fact that when comparing strings, the switch statement acts
as if we're calling the String.equals method, so as long
as we convert the command to lower case, it'll act identically to calling
String.equalsIgnoreCase repeatedly.
while (true) {
System.out.println(game.getPlayerCavern());
System.out.println("Health: " + hitPoints + " arrows: " + game.getQuiver());
System.out.println(">");
String command = br.readLine();
HuntTheWumpus.Command c;
switch (command.toLowerCase()) {
case "e": c = game.makeMoveCommand(EAST);
break;
case "w": c = game.makeMoveCommand(WEST);
break;
case "n": c = game.makeMoveCommand(NORTH);
break;
case "s": c = game.makeMoveCommand(SOUTH);
break;
case "se": c = game.makeShootCommand(EAST);
break;
case "sw": c = game.makeShootCommand(WEST);
break;
case "sn": c = game.makeShootCommand(NORTH);
break;
case "ss": c = game.makeShootCommand(SOUTH);
break;
case "q": return;
default: c = game.makeRestCommand();
}
c.execute();
}
These conditions are now, in my opinion, more readable, and operate in O(1) time. Additionally, the command parsing is visually separated from the user output (which is a sort of UI).
Rather than making the command matches here, the architecture would benefit even more from
taking Martin's advice from his own book, and moving the UI elements to their own component.
If we wish to implement a more complex UI in the future, even a GUI, only the dedicated
component will need to change considerably. In the shorter-term, perhaps we'll want to add
an explicit "i" command which prints this out. It
would be nice to separate this concern from the "dirty"
Main component.
The main game loop could still live in a clean main method,
but we should restrict it to only getting the command and executing it. This leaves the looping
action at this, the lowest "dirtiest" level, while abstracting away the complications
of interpreting user input. Here's how well we clean it up by separating the concerns via
abstraction:
while (true) {
GameUI.displayUserStatus();
HuntTheWumpus.Command c = GameUI.getUserCommand();
c.execute();
}
Map generation
The createMap method may indeed be at home in the
Main class, but surely we can clean it up. Uncle Bob leaves
needless repetition in the same method where he used a loop to avoid it.
First, let's look at the cavern connection block, which uses some not-printed method to
dynamically generate connections between caverns. This isn't so bad, but a nested loop could
abstract it a little better, especially if we wanted to change the cavern geometry in the future
(I'm thinking hexagons, which are the bestagons).
We'll make the further improvement of moving our directions into an enum, which we'll
simply call Direction.
for (String cavern : caverns) {
for (Direction direction : Direction.values()) {
maybeConnectCavern(cavern, direction);
}
}
Next, we have a block which spawns the characters, some bats and some pits into presumably
random unique caverns. Placing the player and the Wumpus are single statements and probably
always will be, but we don't need to repeat ourselves thrice for bats and pits each.
Let's also rename the anyOther method to
anyOtherCavern to reduce ambiguity.
Along with populating caverns, our map generation block gives the player a quiver of arrows?
This has nothing to do with creating the map! Let's move that to a method called
createPlayer, which we'll relegate to the "much code
removed..." section.
String playerCavern = anyCavern();
game.setPlayerCavern(playerCavern);
game.setWumpusCavern(anyOther(playerCavern));
IntStream.range(0, 3).forEach(() -> game.addBatCavern(anyOtherCavern(playerCavern)));
IntStream.range(0, 3).forEach(() -> game.addPitCavern(anyOtherCavern(playerCavern)));
Using IntStream.range is about the closest we can get to a
proper range loop like Python's for x in range(i, j), and
I much prefer it to for loops.
Why did I leave the bat and pit cavern population loops separated? Because they're not inherently linked, and we may reasonably wish to change the frequency of one without changing the other.
Hard-coded values
When an application hard-codes values as severely as this example, it's hard to avoid cringing at the looming technical debt. The severity we see here would be perfectly acceptable in an early computer science course, but a real-world system would struggle to keep up with changing requirements. A simple typo in a string, an additional witty cavern description or the substitution of localized languages should not require code changes.
Similarly, values such as the initial player HP, the number of arrows in the player's quiver, the seed for the randomly generated number of caverns, and the number of bat and pit caverns, all should be configurable with ease. Perhaps we wish to introduce difficulty levels which change the balance of these values. Perhaps we find we've given the player too much HP for a fair fight. We'll undoubtedly need to balance these values, and so we'll be better off storing them in an configurable but immutable data structure.
As detailed in previous chapters of Clean Architecture, the data structure to house these values
shouldn't matter to our Main component. They could reside
in a key-value store, a database of any kind, a CSV or TSV, or even a well formatted plain text
file. As far as this component knows, they're all just an interface. For our purposes
we'll call the interface GameConfiguration, which is
responsible for loading and providing the configured values.
Putting all our changes together with the interface-provided configuration, we arrive at a much cleaner architecture than Uncle Bob presents.
public class Main implements HtwMessageReceiver {
private static HuntTheWumpus game;
private static final List<String> caverns = new ArrayList<>();
private static int hitPoints;
private static int quiver;
private static String[] environments, shapes, cavernTypes, adornments;
public static void main(String[] args) throws IOException {
environments = GameConfiguration.get("environments");
shapes = GameConfiguration.get("shapes");
cavernTypes = GameConfiguration.get("cavernTypes");
adornments = GameConfiguration.get("adornments");
hitPoints = GameConfiguration.get("hitPoints");
quiver = GameConfiguration.get("quiver");
game = HtwFactory.makeGame("htw.game.HuntTheWumpusFacade", new Main());
createMap();
createPlayer();
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
game.makeRestCommand().execute();
while (true) {
GameUI.displayUserStatus();
HuntTheWumpus.Command c = GameUI.getUserCommand();
c.execute();
}
}
private static void createMap() {
int nCaverns = (int) (Math.random()
* GameConfiguration.get("cavernSeed")
+ GameConfiguration.get("cavernMinimum"));
while (nCaverns-- > 0)
caverns.add(makeName());
for (String cavern : caverns) {
for (Direction direction : Direction.values()) {
maybeConnectCavern(cavern, direction);
}
}
String playerCavern = anyCavern();
game.setPlayerCavern(playerCavern);
game.setWumpusCavern(anyOther(playerCavern));
IntStream.range(0, GameConfiguration.get("batCaverns"))
.forEach(() -> game.addBatCavern(anyOtherCavern(playerCavern)));
IntStream.range(0, GameConfiguration.get("pitCaverns"))
.forEach(() -> game.addPitCavern(anyOtherCavern(playerCavern)));
}
// much code removed...
}
The resulting code is more terse, hardy and generally cleaner. Is some of this overkill for a small pet or student project? It probably is, but Uncle Bob presents this as a contrived but real-world example in a printed book about code design, and should have taken the time to apply his own principles to his examples.