Beyond Patterns: What Conway's Game of Life Teaches Us About Software Design

How foundational principles reveal both excellence and pathology in real code


Note: This analysis is based on the design principles dojo created by Ilias Bartolini. The dojo provides an excellent foundation for exploring software design through Conway's Game of Life. This post extends that work by evaluating the codebase through a comprehensive framework of foundational principles.

When developers discuss "good design," conversations typically orbit around patterns, SOLID principles, and best practices harvested from conference talks. We learn to recognize the Strategy pattern, avoid god objects, and keep our classes small. These heuristics are useful, but they're symptoms of deeper truths.

What if we could evaluate code through principles so fundamental that they explain why any pattern works or fails? What if we understood the physics of software - the irreducible forces that shape every design decision?

This post dissects a real implementation of Conway's Game of Life, examining it through a lens that transcends frameworks and fashions. We'll explore how causality, boundaries, constraints, temporal ordering, coupling, cohesion, abstraction, and knowledge management manifest in actual code. More importantly, we'll see how violations of these principles create the exact problems we spend our careers fighting.

By the end, you'll have a diagnostic toolkit that doesn't require memorizing patterns. You'll recognize problems at their root and understand solutions at their essence.

The Analytical Framework

Before examining the code, let's establish our evaluative principles. These aren't arbitrary rules, they're the fundamental forces that govern software behavior.

The Four Foundational Principles

Causality structures how cause and effect flow through your system. Clear causality means you can trace any behavior back to its source. When X happens, Y follows predictably. Poor causality manifests as mysterious bugs, unpredictable state changes, and code where you can't explain why something happened.

Boundaries determine how you partition concerns. Good boundaries mean changes in one area don't ripple everywhere else. They control coupling and enable reasoning about parts in isolation. Poor boundaries create tangled webs where everything depends on everything.

Constraints make illegal states unrepresentable. When you encode invariants in types, entire bug categories vanish. You're not preventing errors at runtime—you're making them impossible to write. Poor constraint management means spending your life validating inputs and wondering why production keeps finding edge cases.

Temporal Ordering governs how systems evolve through time. This includes state transitions, operation sequencing, and concurrency. Get it wrong, and you'll battle race conditions, temporal coupling, and systems where the order of operations mysteriously matters.

These four are necessary and sufficient. Ignore any one, and you get fundamental problems. Together, they explain every architectural pattern you've encountered.

Coupling and Cohesion: The Relationship Dimensions

Most developers think coupling means "fewer dependencies." This leads to absurd conclusions: god classes reduce coupling (everything's in one place!), and we should avoid dependencies entirely. This is backwards.

Coupling isn't about quantity—it's about dependency direction, stability, and knowledge. Good coupling means volatile modules depend on stable ones, not vice versa. It means modules know about interfaces, not implementations. Poor coupling means changes cascade: modify one thing, break five others.

Cohesion measures how strongly elements within a module work toward a single purpose. High cohesion means everything in the module changes for the same reason and contributes to the same responsibility. Low cohesion is a god object: multiple unrelated responsibilities bundled together because they happened to touch the same data.

Together, coupling and cohesion create the structural integrity of your design. Good systems have low coupling between modules and high cohesion within them.

DRY: Knowledge Management, Not Code Deduplication

The "Don't Repeat Yourself" principle is widely misunderstood. Developers see identical code and reflexively extract methods. But DRY isn't about avoiding duplicate lines—it's about avoiding duplicate knowledge.

When the same business fact appears in three places, you don't have one truth—you have three truths that will inevitably diverge. Change the VAT rate in one class, and two others are already wrong. This is the real cost of violating DRY: bugs multiply when knowledge scatters.

To evaluate whether code violates DRY, ask two questions:

Do these code sections encode the same fact?

Will they always change together?

If both answers are yes, consolidate them. If either is no, they're coincidentally similar, not duplicated knowledge.

Abstractions: Purpose Over Mimicry

Abstractions aren't about hiding complexity or mimicking reality. They're about exposing the right concerns for a specific purpose while hiding everything else.

A good abstraction is selective. It highlights what matters and darkens what doesn't, based on context. The purpose isn't simplification—it's organization. An abstraction that reflects real-world fidelity too closely often creates clumsy models. Instead, abstract what you need to reason about, not what the world contains.

The test: Can you articulate the abstraction's purpose in one sentence?

What concerns does it expose?

What complexity does it hide?

If you can't answer clearly, the abstraction is probably weak.

The Codebase: Conway's Game of Life

Our subject is a Java implementation of Conway's Game of Life—a cellular automaton where cells live or die based on simple rules.

A living cell with 2-3 living neighbors survives.

A dead cell with exactly 3 living neighbors comes alive.

Everything else dies or stays dead.

The implementation uses object-oriented design with separation between domain logic and UI presentation. It looks reasonable at first glance. But when we apply our analytical framework, both elegant design and fundamental flaws become visible.

Let's start with excellence.

When Design Principles Align: The Cell Abstraction

The most elegant part of this codebase demonstrates how multiple principles can align perfectly:

public interface Cell {
    boolean isAlive();
    boolean willBeAlive(int numberOfAliveNeighbours);
}

public class AliveCell implements Cell {
    @Override
    public boolean isAlive() {
        return true;
    }

    @Override
    public boolean willBeAlive(int numberOfAliveNeighbours) {
        return numberOfAliveNeighbours == 2 || numberOfAliveNeighbours == 3;
    }
}

public class DeadCell implements Cell {
    @Override
    public boolean isAlive() {
        return false;
    }

    @Override
    public boolean willBeAlive(int numberOfAliveNeighbours) {
        return numberOfAliveNeighbours == 3;
    }
}

This design is exceptional because it satisfies every principle simultaneously.

Causality: Crystal Clear

The cause-effect relationship couldn't be more explicit.

Input: number of living neighbors.

Output: future state.

No side effects, no hidden state, no mystery. You can trace the complete causality chain by reading the code once.

More importantly, polymorphism encodes domain causality directly in the type structure.

The question "why did this cell survive?" has an immediate answer: look at the cell type's implementation.

The decision logic isn't scattered across conditionals—it's embedded where it belongs.

Abstraction: Purposeful and Selective

What's the purpose of the Cell abstraction? "Represent a cell's state and survival rules." Clear and singular.

What concerns does it expose? Liveness status and next-generation behavior. Nothing about position, rendering, or history.

What complexity does it hide? The implementation details of survival rules. Consumers don't need to know whether a cell is implemented as an enum, a class, or something else.

This abstraction is selective—it captures what matters (alive/dead status, survival logic) and ignores what doesn't (everything else about cells). This selectivity makes it both usable and portable.

Cohesion: Perfect Unity

Every element in AliveCell serves a single purpose: defining what it means to be an alive cell. Both methods contribute to this responsibility. The isAlive() method declares the cell's current state. The willBeAlive() method encodes survival rules specific to living cells.

These aren't unrelated methods bundled together. They're interdependent elements working as a cohesive unit. There's exactly one reason this class would change: if the Game of Life rules for living cells changed.

Constraints: Types as Truth

Notice what's impossible in this design:

You cannot ask a cell for its survival rules without also knowing whether it's alive. The interface enforces this: both questions are answered together.

You cannot accidentally apply dead-cell rules to living cells or vice versa. The polymorphism guarantees each cell type uses its own rules.

You cannot create a cell in an undefined state. It's either AliveCell or DeadCell—there's no third option, no null state, no "unknown" cells.

These constraints aren't enforced through validation code. They're embedded in the type structure itself. The compiler prevents entire categories of errors.

Why This Matters

This Cell abstraction isn't just "good OOP." It demonstrates how principles reinforce each other. Clear causality enables strong abstractions. Strong abstractions enable better constraints. Good constraints improve cohesion. High cohesion clarifies causality.

When principles align, the code almost writes itself. Problems become easy. Changes become safe. The design feels inevitable.

Now let's see what happens when principles diverge.

When Design Principles Collide: The World God Object

The central class in this codebase is World, which manages the game state:

public class World {
    Map<Location, Cell> cells;
    
    public void advance() { ... }
    public boolean isEmpty() { ... }
    public void setLiving(Location location) { ... }
    public boolean isAlive(Location location) { ... }
    public int numberOfAliveNeighbours(Location l) { ... }
    private Map<Location,Cell> initCells() { ... }
}

This class violates nearly every principle we've discussed. Let's examine how.

Cohesion: Multiple Responsibilities

What's the purpose of World? Is it a state container? A game engine? A query interface? A factory? It's all of these, which means it's none of them clearly.

To see the cohesion problem, ask: "What reasons might this class have to change?"

If cell storage strategy changes (switching from HashMap to Array), World changes.

If game rules change (adding new evolution algorithms), World changes.

If querying needs change (adding new query methods), World changes.

If initialization logic changes (different starting patterns), World changes.

If the neighbor-finding algorithm changes, World changes.

That's five different reasons for one class to change. Each represents a separate responsibility. World has low cohesion - its elements aren't working together toward a single purpose. They're just bundled together because they all touch the same data structure.

Compare this to AliveCell, which has exactly one reason to change: Game of Life rules for living cells. That's high cohesion.

Abstraction: Purpose Lost

Try to articulate World's purpose in one sentence. "World manages game state and evolution and queries and initialization." That's not one purpose - it's four purposes pretending to be one.

What concerns does World expose?

State (the cells map), mutation (setLiving), queries (isAlive, isEmpty), evolution (advance), and algorithms (numberOfAliveNeighbours). Too many concerns mean the abstraction isn't selective. It's exposing everything, which means it's not abstracting anything.

What complexity does World hide? Nothing, really. Callers must understand Locations, Cells, neighbors, state transitions, and the internal data structure. There's no separation between interface and implementation.

A good abstraction has a clear purpose and exposes only what's necessary for that purpose. World fails both tests.

Coupling: Wrong Direction

The World class couples to Location, Cell, and several implementation details. But look at this method in Location:

public List<Location> allNeighbours(int worldWidth, int worldHeight) {
    int lowerX = Math.max(0, x - 1);
    int upperX = Math.min(worldWidth - 1, x + 1);
    // ...
}

This is coupling in the wrong direction. Location, which should be a stable, reusable value object, depends on World's dimensions, which are volatile and context-specific. The dependency arrow points from stable to volatile - exactly backwards.

Why does this matter? Because now Location can't be reused in different contexts. Want to use Location in a different game with different boundaries? You can't—it's coupled to World's specific size. Want to test Location's neighbor logic? You need to pass World dimensions into every test.

The correct direction is: World should know about Locations, not the other way around. Volatile modules (World with its mutable state) should depend on stable modules (Location as a pure coordinate), not vice versa.

Causality: Mutation Obscures Effects

Look at the advance method:

public void advance() {
    Map<Location, Cell> newCells = initCells();
    
    for (Location location : allWorldLocations(DEFAULT_WIDTH, DEFAULT_HEIGHT)) {
        if (cells.get(location).willBeAlive(numberOfAliveNeighbours(location))){
            newCells.put(location, new AliveCell());
        }
    }
    cells = newCells;  // Old state destroyed
}

The causality problem is subtle but profound. This method mutates the world's state in place. The old state is discarded. There's no explicit representation of the state transition.

This makes causality implicit: World(t₀) --advance()- [World mutated] - ???

You can't trace the transformation because the input (old state) is destroyed. You can't answer "what did the world look like before?" because that information is gone. The world is simultaneously the cause and the effect, making it impossible to reason about the transition.

Compare this to an explicit state transition: World(t₀) --advance()- World(t₁). Now both cause and effect are visible. You can inspect the old world, examine the new world, and understand the transformation.

Temporal Ordering: Time Becomes Invisible

Conway's Game of Life is fundamentally about time evolution. Cells change generation by generation. History matters - patterns emerge over multiple steps.

Yet time is invisible in this design. There's no concept of generations, no history, no way to rewind or examine previous states. The world just mutates, and the past vanishes.

This isn't just a theoretical concern. It has practical implications:

Can you implement - rewind to previous generation? No, because history is destroyed.

Can you show a timeline of evolution? No, because you only have the current state.

Can you test - after 5 generations, this pattern should emerge? Only by running advance five times and hoping nothing goes wrong.

Time should be a first-class concept in a system about temporal evolution. Instead, it's implicit in mutation.

Constraints: Illegal States Everywhere

What prevents creating a World with negative dimensions? Nothing - there's no validation.

What prevents adding a cell outside the grid boundaries? Nothing - setLiving(at(100, 100)) will happily create a cell that shouldn't exist.

What prevents calling setLiving during iteration, causing concurrent modification? Nothing - the API permits it.

What prevents mixing construction and evolution in problematic ways? Nothing - you can call setLiving, advance, setLiving, advance in any order, potentially creating states that violate game rules.

These illegal states are all representable. The type system doesn't prevent them. Runtime validation doesn't catch them. They're bugs waiting to happen.

The Root Cause: God Object Pattern

All these problems stem from one design decision: putting too many responsibilities in one class. World tries to be everything - state container, algorithm implementation, query interface, mutation API, factory, and evolution engine.

When a class does too much, it violates every principle:

Low cohesion creates unclear purposes (abstraction failure).

Unclear purposes create tangled dependencies (coupling problems).

Tangled dependencies make causality hard to trace.

Difficult causality makes temporal reasoning impossible.

Impossible temporal reasoning means you can't enforce constraints effectively.

The problems cascade. One violation creates others.

The Coupling Deep Dive: Direction, Stability, and Knowledge

Let's zoom in on coupling to understand what "wrong direction" really means and why it matters so much.

Coupling Isn't About Quantity

A common misconception is that fewer dependencies equals better design. This leads to unfortunate conclusions:

"We should avoid creating new classes because that increases dependencies." (Wrong - it might decrease coupling by clarifying boundaries.)

God objects are fine because everything's in one place. (Wrong - they maximize coupling by forcing everything to depend on one monolith.)

We should minimize imports. (Wrong - the number of imports says nothing about coupling quality.)

Coupling is about three things: direction, stability, and knowledge. Let's examine each.

Dependency Direction: Stable vs. Volatile

In any codebase, some modules change frequently (volatile) and others change rarely (stable). Good architecture makes volatile modules depend on stable ones.

Why? Because if a stable module depends on a volatile one, every change to the volatile module potentially breaks the stable one. You've created a cascade: change ripples through the system.

In this Game of Life code, consider the UI and core layers:

UI (presenters, screens, rendering) -> Core (World, Cell, Location)

This direction is correct. The UI is volatile - presentation concerns change often. You might swap Swing for JavaFX, add new visual effects, or redesign screens. The core is stable - Game of Life rules don't change.

Because the arrow points from volatile to stable, UI changes don't affect core logic. You can completely rewrite the presentation without touching game rules. This is excellent coupling direction.

Now consider Location depending on World dimensions:

Location (stable, reusable) <- World (volatile, specific)

This is backwards. Location should be a stable value object usable anywhere. But it depends on World's specific dimensions, which are volatile and context-dependent. Now you can't reuse Location without dragging World along. The dependency arrow points from stable to volatile - the wrong direction.

Coupling as Knowledge

Another way to understand coupling is through knowledge: how much does one module need to know about another to function?

The Cell interface demonstrates low knowledge coupling:

public interface Cell {
    boolean willBeAlive(int numberOfAliveNeighbours);
}

To use a Cell, you need to know only two things: cells can report their survival status given a neighbor count. You don't need to know how cells store state, what implementation they use, or how they make decisions. The interface reveals minimal knowledge.

Contrast with World's implementation:

public void advance() {
    Map<Location, Cell> newCells = initCells();
    for (Location location : allWorldLocations(DEFAULT_WIDTH, DEFAULT_HEIGHT)) {
        if (cells.get(location).willBeAlive(numberOfAliveNeighbours(location))){
            newCells.put(location, new AliveCell());
        }
    }
    cells = newCells;
}

To understand this method, you need to know: the internal data structure (HashMap), how Locations work, how Cells work, how initCells works, how numberOfAliveNeighbours works, and the implications of mutating the cells field.

That's high knowledge coupling. The method reveals and depends on many implementation details. Changes to any of these details might force changes here.

The Coupling-Cohesion Relationship

Low coupling and high cohesion work together. When a class has high cohesion (single purpose), it needs less from other classes, creating low coupling. When a class has low cohesion (multiple purposes), it needs more from other classes, creating high coupling.

World has low cohesion (many responsibilities) and high coupling (depends on many implementation details). This isn't coincidence, they're related.

If we split World into focused classes (WorldState, GameRules, WorldBuilder), each would have high cohesion and low coupling. Each would do one thing and need less knowledge from others.

DRY: When Similar Code Isn't Duplication

The Game of Life code creates cells in several places:

// In World.advance()
newCells.put(location, new AliveCell());

// In World.setLiving()
cells.put(location, new AliveCell());

// In World.initCells()
cells.put(location, new DeadCell());

Is this a DRY violation? The code looks similar - all three lines create cells and add them to maps. Should we extract a method?

Not necessarily. Let's apply the DRY test:

Do these encode the same fact? Let's see:

  • advance() creates cells based on Game of Life rules
  • setLiving() creates cells during initial setup
  • initCells() creates an empty grid

These are three different facts: evolutionary rules, initial configuration, and grid initialization. They happen to use the same mechanism (creating cells), but they represent different domain knowledge.

Will they always change together? Unlikely:

  • If Game of Life rules change, only advance changes
  • If initialization strategy changes, only setLiving and initCells change
  • If we add cell pooling, all three might change, but for different reasons

The code looks similar, but it's not duplicated knowledge. It's coincidentally similar implementation of different concerns.

However, there is a DRY violation hidden here. If we later need to add validation, logging, or cell pooling, we'd need to change all three places. The knowledge "how to correctly instantiate cells in this system" is scattered.

The fix isn't extracting the new AliveCell() call—it's consolidating the knowledge of cell creation:

public class CellFactory {
    public static Cell aliveCell() {
        // One place for all cell creation logic
        Cell cell = new AliveCell();
        logCellCreation(cell);  // If we add logging, change it here
        return cell;
    }
    
    public static Cell deadCell() {
        Cell cell = new DeadCell();
        logCellCreation(cell);
        return cell;
    }
}

Now there's one source of truth for "how to create cells." This is what DRY actually means: consolidating knowledge, not eliminating similar-looking code.

The DRY Principle in Perspective

The deeper insight is that DRY is about knowledge management, not code deduplication. When you see similar code, ask:

Do these represent the same business fact or domain knowledge? If a VAT rate appears in three places, that's one fact scattered three ways. If three different calculations happen to use similar loops, those might be three different facts with coincidentally similar implementation.

Will these always change together? If changing business rules requires updating all these places, they're duplicated knowledge. If each might change independently for different reasons, they're separate concerns.

Is there shared knowledge that would be costly to keep synchronized? Cell creation is shared knowledge. Three similar for-loops over unrelated data structures might not be.

DRY violations create bugs when knowledge diverges. Fix the knowledge duplication, not the code duplication.

The Boundary Violation: Location's Identity Crisis

One of the subtlest but most instructive design flaws in this codebase is Location's dependency on World dimensions. Let's examine why this matters and what it teaches about boundaries.

public class Location {
    public final int x;
    public final int y;
    
    public List<Location> allNeighbours(int worldWidth, int worldHeight) {
        int lowerX = Math.max(0, x - 1);
        int upperX = Math.min(worldWidth - 1, x + 1);
        int lowerY = Math.max(0, y - 1);
        int upperY = Math.min(worldHeight - 1, y + 1);
        
        for (int i = lowerX; i <= upperX; i++) {
            for (int j = lowerY; j <= upperY; j++) {
                if (i != x || j != y) {
                    neighbours.add(at(i, j));
                }
            }
        }
        return neighbours;
    }
}

What is Location's responsibility? It's a coordinate - a point in 2D space. Representing coordinates is stable and reusable. You could use this concept in chess, tic-tac-toe, or a map application.

But this Location also knows about world boundaries. It takes worldWidth and worldHeight as parameters and uses them to clip neighbor calculations. This means Location now knows about a specific context (a bounded grid) rather than being a pure spatial concept.

The Boundary Principle

A good boundary separates concerns so each can change independently. Location should handle spatial relationships (what are my neighbors in infinite 2D space?). World should handle contextual constraints (which neighbors are actually valid in this bounded grid?).

When Location knows about boundaries, the boundary between spatial logic and domain constraints has been violated. The concerns are tangled.

Why This Violation Matters

Reusability: Location can't be reused in contexts with different boundary rules. What if you want a toroidal world where edges wrap around? Location's implementation assumes a bounded rectangle. You'd need to modify Location - a supposedly stable, reusable component.

Testability: Every test of Location's neighbor logic requires passing boundary parameters. Want to test that a location correctly finds eight neighbors? You need to mock World dimensions. The test becomes more complex than the logic being tested.

Coupling Direction: Location (stable) depends on World dimensions (volatile). This is backwards. Changes to how worlds handle boundaries now ripple down to affect Location.

Cohesion: Location is doing two jobs: representing coordinates and applying boundary constraints. These are separate concerns with different reasons to change.

The Correct Boundary

Here's how to properly separate these concerns:

// Location: Pure spatial relationships
public class Location {
    public final int x;
    public final int y;
    
    public List<Location> allNeighbours() {
        // Returns all 8 neighbors in infinite 2D space
        return List.of(
            at(x-1, y-1), at(x, y-1), at(x+1, y-1),
            at(x-1, y),              at(x+1, y),
            at(x-1, y+1), at(x, y+1), at(x+1, y+1)
        );
    }
}

// World: Domain constraints
public class World {
    private boolean contains(Location loc) {
        return loc.x >= 0 && loc.x < width 
            && loc.y >= 0 && loc.y < height;
    }
    
    private int numberOfAliveNeighbours(Location location) {
        return location.allNeighbours()
            .stream()
            .filter(this::contains)  // World enforces its boundaries
            .filter(this::isAlive)
            .count();
    }
}

Now the boundary is clear. Location handles spatial math. World handles domain rules. Each can change independently. Location becomes reusable in any context. World can easily implement different boundary strategies (wrapping, extending, custom shapes).

This is what good boundaries look like: clear separation of concerns with well-defined interfaces between them.

Constraints: Making Illegal States Unrepresentable

One of the most powerful techniques in software design is using the type system to prevent errors rather than catching them. When you make illegal states unrepresentable, entire categories of bugs vanish.

The Game of Life code misses many opportunities to enforce constraints through types. Let's examine what's possible and what remains representable that shouldn't be.

The Missing Validation: World Dimensions

public class World {
    public static final int DEFAULT_WIDTH = 10;
    public static final int DEFAULT_HEIGHT = 10;
    Map<Location, Cell> cells;
}

What prevents creating a World with negative dimensions? What prevents creating a World with zero width? What prevents creating a World with dimensions that don't match the cell map?

Nothing. These illegal states are all representable. The type system permits them. There's no runtime validation. You'll only discover the problem when something mysteriously breaks later.

The type-based solution makes these states impossible:

public class World {
    private final PositiveInt width;
    private final PositiveInt height;
    private final Map<Location, Cell> cells;
    
    private World(PositiveInt width, PositiveInt height, Map<Location, Cell> cells) {
        this.width = width;
        this.height = height;
        this.cells = validateCells(width, height, cells);
    }
    
    private Map<Location, Cell> validateCells(
        PositiveInt width, 
        PositiveInt height, 
        Map<Location, Cell> cells
    ) {
        cells.keySet().forEach(loc -> {
            if (loc.x < 0 || loc.x >= width.value() || 
                loc.y < 0 || loc.y >= height.value()) {
                throw new IllegalStateException(
                    "Cell at " + loc + " is outside world bounds"
                );
            }
        });
        return new HashMap<>(cells);
    }
}

// PositiveInt: Makes negative/zero values unrepresentable
public class PositiveInt {
    private final int value;
    
    private PositiveInt(int value) {
        this.value = value;
    }
    
    public static PositiveInt of(int value) {
        if (value <= 0) {
            throw new IllegalArgumentException(
                "Value must be positive, got: " + value
            );
        }
        return new PositiveInt(value);
    }
    
    public int value() {
        return value;
    }
}

Now it's impossible to create a World with invalid dimensions. The type system enforces this constraint. You can't even construct a PositiveInt with a negative value - the factory method prevents it.

This is better than runtime validation because:

Errors are caught earlier: At construction time, not when something breaks mysteriously later.

Errors are caught once: You validate when creating PositiveInt, then use it everywhere without re-validating.

The type system helps: IDE autocomplete shows PositiveInt, signaling the constraint to developers.

Tests become simpler: No need to test negative-dimension cases—they're impossible.

The Missing Constraint: Cell Location Validity

Currently, you can add cells anywhere:

World world = new World();
world.setLiving(at(100, 100));  // Outside the 10x10 grid, but no error!

This illegal state is representable. The cell map now contains an entry that violates the world's boundaries. The system is in an inconsistent state, and you won't discover it until something tries to query that location and gets confused.

The type-based solution makes out-of-bounds locations unrepresentable:

// GridLocation: Can only be created by World, guaranteed valid
public class GridLocation {
    private final int x;
    private final int y;
    
    // Private constructor: only World can create these
    private GridLocation(int x, int y) {
        this.x = x;
        this.y = y;
    }
    
    // Package-private factory: only World package can use this
    static GridLocation create(int x, int y) {
        return new GridLocation(x, y);
    }
}

public class World {
    private final int width;
    private final int height;
    private final Map<GridLocation, Cell> cells;
    
    // Returns Option to signal "this might not be valid"
    public Optional<GridLocation> locationAt(int x, int y) {
        if (x >= 0 && x < width && y >= 0 && y < height) {
            return Optional.of(GridLocation.create(x, y));
        }
        return Optional.empty();
    }
    
    // Only accepts validated locations
    public World withLivingCell(GridLocation location) {
        // location is GUARANTEED valid - no need to check!
        Map<GridLocation, Cell> newCells = new HashMap<>(cells);
        newCells.put(location, new AliveCell());
        return new World(width, height, newCells);
    }
}

Now it's impossible to add a cell outside the grid. You can't even construct an invalid GridLocation, only World can create them, and it only creates valid ones. The type system guarantees this constraint.

Usage becomes safer:

World world = new World(10, 10);

// Must handle the "might not exist" case
world.locationAt(100, 100).ifPresent(loc -> {
    world = world.withLivingCell(loc);  // Will never execute
});

// Valid location
world.locationAt(5, 5).ifPresent(loc -> {
    world = world.withLivingCell(loc);  // Guaranteed safe
});

The Optional signals "this location might not be valid" at the type level. The compiler forces you to handle this case. You can't accidentally use an invalid location, the type system prevents it.

The Power of Type-Level Constraints

Notice what we've achieved: we haven't prevented errors through more validation code. We've made the errors impossible to write in the first place.

This is the highest form of constraint enforcement: making illegal states literally unrepresentable in the type system. When you succeed at this, you get:

Compile-time safety: Bugs are caught when you build, not when you deploy.

Self-documenting code: Types signal constraints. GridLocation communicates "this is a valid location" without comments.

Simpler logic: No need for defensive programming. If a method accepts GridLocation, it knows the location is valid.

Better tests: No need to test invalid cases - they're impossible to construct.

The Game of Life code misses these opportunities. Illegal states are representable throughout: negative dimensions, out-of-bounds cells, inconsistent state. Each represents a latent bug waiting for the right conditions to manifest.

Temporal Ordering: The Invisible Dimension

Conway's Game of Life is fundamentally a system about time. Cells evolve generation by generation. Patterns emerge over temporal sequences. History matters.

Yet time is almost invisible in this implementation. It's hidden in mutation, implicit in method calls, and impossible to reason about explicitly. This creates subtle but significant problems.

Time Hidden in Mutation

public void advance() {
    Map<Location, Cell> newCells = initCells();
    for (Location location : allWorldLocations(DEFAULT_WIDTH, DEFAULT_HEIGHT)) {
        if (cells.get(location).willBeAlive(numberOfAliveNeighbours(location))){
            newCells.put(location, new AliveCell());
        }
    }
    cells = newCells;  // Old state destroyed
}

What happens when you call advance? The world mutates. The old state vanishes. Generation N becomes generation N+1, but you can't see both states simultaneously. The transformation is invisible.

This makes temporal reasoning difficult. You can't answer questions like:

What did the world look like three generations ago? The history is gone.

How did this specific pattern evolve from the initial state? You'd need to re-run from the beginning.

What's the difference between generation 5 and generation 6? You can't compare them because generation 5 no longer exists.

The causality chain is broken. You have effect (current state) but not cause (previous state). Time has been compressed into a single mutable point.

Temporal Coupling in the API

Consider how you initialize and run the simulation:

World world = new World();
world.setLiving(at(7,1));
world.setLiving(at(7,2));
world.setLiving(at(7,3));
// ... later ...
world.advance();

There's a temporal dependency here: you must call setLiving before advance. The API doesn't enforce or clarify this ordering. It's just something you have to know.

What happens if you call advance first? You get an empty world that stays empty forever.

What happens if you call setLiving after advance? You're mixing initial configuration with evolved state - the result is neither a proper initial state nor a proper evolved state.

This is temporal coupling: the order of operations matters, but nothing in the API makes this explicit or enforces it.

Missing: Time as a First-Class Concept

Here's what an explicit temporal model might look like:

// Immutable state at a specific generation
public class WorldState {
    private final int generation;
    private final Map<Location, Cell> cells;
    
    public WorldState(int generation, Map<Location, Cell> cells) {
        this.generation = generation;
        this.cells = cells;
    }
    
    public int generation() {
        return generation;
    }
    
    public boolean isAlive(Location location) {
        return cells.get(location).isAlive();
    }
}

// Pure evolution function
public class GameOfLifeRules {
    public WorldState evolve(WorldState current) {
        Map<Location, Cell> newCells = computeNextGeneration(current);
        return new WorldState(
            current.generation() + 1,
            newCells
        );
    }
}

// Explicit history
public class WorldHistory {
    private final List<WorldState> states;
    
    public WorldHistory(WorldState initial) {
        this.states = List.of(initial);
    }
    
    private WorldHistory(List<WorldState> states) {
        this.states = states;
    }
    
    public WorldHistory advance(GameOfLifeRules rules) {
        WorldState current = currentState();
        WorldState next = rules.evolve(current);
        
        List<WorldState> newStates = new ArrayList<>(states);
        newStates.add(next);
        return new WorldHistory(newStates);
    }
    
    public WorldState currentState() {
        return states.get(states.size() - 1);
    }
    
    public WorldState stateAt(int generation) {
        return states.get(generation);
    }
    
    public int currentGeneration() {
        return states.size() - 1;
    }
    
    public List<WorldState> allStates() {
        return List.copyOf(states);
    }
}

Now time is explicit. Generation is a first-class concept. You can:

// Create initial state
WorldState initial = buildInitialState();
WorldHistory history = new WorldHistory(initial);

// Evolve through time
GameOfLifeRules rules = new GameOfLifeRules();
history = history.advance(rules);
history = history.advance(rules);
history = history.advance(rules);

// Examine any generation
WorldState generation0 = history.stateAt(0);
WorldState generation3 = history.currentState();

// Compare generations
boolean changed = !generation0.equals(generation3);

// Analyze evolution
history.allStates().forEach(state -> {
    System.out.println("Generation " + state.generation() + 
                     ": " + state.livingCellCount() + " cells");
});

Time is no longer hidden. The causality chain is preserved:

Generation₀ -> Generation₁ -> Generation₂ -> Generation₃.

You can inspect any point in time, compare states, and trace evolution.

The Practical Impact

Why does making time explicit matter?

Testing becomes easier: You can write tests like "given this initial state, after 5 generations, expect this end state" without side effects between tests.

Debugging becomes possible: When something goes wrong, you can examine the history to see when the problem appeared.

Features become trivial: Want to implement undo? It's just history.stateAt(history.currentGeneration() - 1). Want to implement playback? Iterate through states. Want to implement time-travel debugging? You already have all the data.

Concurrency becomes safe: Immutable states can be shared across threads without synchronization. Multiple threads can read any generation safely.

Reasoning becomes clear: The system behaves like a mathematical function: old state -> rules -> new state. No mutation, no side effects, no mystery.

The current implementation sacrifices all of this for the minor convenience of mutation. It's a bad trade.

The UI Boundary: When Separation Works

Not everything in this codebase demonstrates poor design. The boundary between UI and core logic shows how proper separation should work.

The package structure makes this clear:

com.thoughtworks.game_of_life
├── core/           # Domain logic: Cell, Location, World
└── ui/             # Presentation: presenters, screens, GameRunner

More importantly, the dependency direction is correct:

UI → Core (correct: volatile depends on stable)

The UI knows about core concepts like World and Cell. It imports these types, calls their methods, and adapts them for display. But the core knows nothing about the UI. There are zero imports from core to ui. The domain logic is UI-agnostic.

Why This Matters

This separation creates several benefits:

Independent Evolution: You can completely rewrite the UI without touching game logic. Swap Swing for JavaFX? Change only ui package. Add a web interface? Create a new ui.web package. The core remains unchanged.

Testing Independence: Core logic can be tested without any UI dependencies. No need to mock graphics, create fake windows, or deal with Swing's threading model. Tests are fast, deterministic, and simple.

Reusability: The core can be used in multiple contexts. Want a command-line version? Web version? Headless simulation for analysis? The same core works for all of them.

Clarity of Purpose: The core has one job: implement Game of Life rules. The UI has a different job: present those rules visually. Each layer's responsibility is clear.

The Adapter Pattern in Practice

The UI uses the Presenter interface to adapt domain objects to rendering:

public interface Presenter {
    void draw(Graphics2D graphics);
}

public class AliveCellPresenter implements Presenter {
    private Location location;
    
    @Override
    public void draw(Graphics2D graphics) {
        graphics.setColor(Color.black);
        graphics.fill(getBounds());
    }
}

public class GamePresenter implements Presenter {
    private World world;
    
    public void draw(Graphics2D graphics) {
        for (Location location : allWorldLocations(...)) {
            Presenter cellPresenter = 
                CellToPresenterFactory.toPresenter(world, location);
            cellPresenter.draw(graphics);
        }
    }
}

This is the Adapter pattern, but more fundamentally, it's an abstraction that solves a specific problem: "How do we render domain objects without coupling domain logic to rendering concerns?"

The answer: Create an interface (Presenter) that translates from domain concepts (Cell, Location, World) to presentation concepts (Graphics2D, rectangles, colors). The domain doesn't know about presenters. Presenters know about the domain.

This maintains the dependency direction: volatile (UI) depends on stable (domain).

What We Can Learn

This UI/Core separation demonstrates several principles working correctly:

Boundaries: Clear separation between concerns. Each side of the boundary has a distinct responsibility.

Coupling: Dependencies flow in the right direction (volatile → stable). The amount of knowledge each side needs about the other is minimal.

Abstraction: The Presenter interface exposes one concern ("can be drawn") and hides implementation details.

Cohesion: Each presenter has a single purpose: translate one domain concept to visual representation.

If the entire codebase followed these principles as well as the UI boundary does, we'd have an excellent design.

Synthesis: How Principles Reinforce or Contradict

We've examined this codebase through multiple lenses. Now let's step back and see how these principles interact.

When Principles Align: The Cell Abstraction

The Cell interface demonstrates reinforcing principles:

Good abstraction (clear purpose: represent cell state and rules)
enables
High cohesion (all elements serve that one purpose)
enables
Clear causality (pure functions: neighbors → survival)
enables
Strong constraints (polymorphism makes invalid cell states unrepresentable)
results in
Low coupling (minimal knowledge required to use cells)

Each principle reinforces the others. When you make the abstraction clear, cohesion follows naturally. When cohesion is high, causality becomes clearer. When causality is clear, you can enforce better constraints. When constraints are strong, coupling decreases.

This creates a virtuous cycle. Good decisions compound.

When Principles Conflict: The World God Object

The World class demonstrates cascading violations:

Weak abstraction (unclear purpose: state + queries + mutation + evolution)
causes
Low cohesion (multiple responsibilities in one class)
causes
Wrong coupling (depends on everything, everything depends on it)
causes
Poor causality (mutation hides state transitions)
causes
Lost temporal ordering (time becomes implicit)
causes
Weak constraints (illegal states become representable)

Each violation creates others. When the abstraction is unclear, responsibilities multiply. When responsibilities multiply, coupling increases. When coupling increases, causality becomes harder to trace. When causality is unclear, temporal reasoning suffers. When temporal reasoning suffers, constraint enforcement weakens.

This creates a vicious cycle. Bad decisions compound.

The Takeaway: Design as Reinforcing Principles

Software design isn't about applying isolated techniques. It's about understanding how principles interact and reinforcing good patterns while avoiding cascading failures.

When you make a design decision, ask: Does this strengthen or weaken each principle? Will this decision compound or cascade?

The Cell abstraction compounds: one good decision creates multiple benefits.

The World god object cascades: one bad decision creates multiple problems.

Lessons for Practitioners

What should you take from this analysis? Not specific patterns or refactorings, but a way of thinking about design.

Diagnose With Principles, Not Symptoms

When you encounter a problematic codebase, don't just list symptoms ("this class is too big," "these methods are confusing," "tests are hard to write").

Diagnose the principle violations:

This class violates cohesion - it has five reasons to change.

This dependency violates coupling direction - stable depends on volatile.

This mutation violates causality - I can't trace state transitions.

This API violates temporal ordering - the sequence of calls matters but isn't enforced.

This type violates constraints - illegal states are representable.

Principle-based diagnosis reveals root causes. Symptom-based diagnosis treats effects.

Evaluate Designs Objectively

When reviewing code or design proposals, use principles as your rubric:

Causality: Can I trace inputs to outputs? Are effects predictable from causes?

Boundaries: Does each module have a clear responsibility? Do dependencies flow correctly?

Constraints: What illegal states are representable? Could we make them impossible?

Temporal Ordering: Is time explicit or hidden? Is operation sequence enforced or assumed?

Coupling: Do volatile modules depend on stable ones? How much knowledge is required?

Cohesion: Do all elements serve one purpose? Is there one reason to change?

Abstraction: What's the purpose? What's exposed? What's hidden?

DRY: Is knowledge duplicated? Will changes require synchronized updates?

This framework works regardless of language, paradigm, or domain. It's not "Java best practices" or "functional programming patterns." It's the physics of software.

Learn Patterns Through Principles

When you encounter a new pattern or practice, ask: What principle does this reinforce?

Dependency Injection -> Coupling (control dependency direction)

Immutability -> Causality (make effects traceable) + Temporal Ordering (make time explicit)

Factory Pattern -> Constraints (centralize creation logic to enforce invariants)

Interface Segregation -> Abstraction (expose only relevant concerns) + Coupling (reduce knowledge requirements)

Single Responsibility -> Cohesion (one reason to change)

Null Object Pattern -> Constraints (make null unrepresentable)

Now you're not memorizing patterns. You're understanding their purpose. This makes them easier to remember, easier to apply, and easier to adapt.

Build Intuition That Transfers

The most valuable skill isn't knowing specific solutions. It's developing intuition that works across contexts.

When you understand principles, you can evaluate designs you've never seen, in languages you don't use, for domains you don't know. You can look at a Rust async system and recognize causality problems. You can look at a React component and recognize coupling violations. You can look at a database schema and recognize missing constraints.

The principles transfer because they're fundamental. They're not artifacts of tools or trends. They're the structure of computation itself.

Conclusion: Beyond Patterns to Principles

This analysis of Conway's Game of Life has revealed a crucial insight: good design isn't about following rules or applying patterns. It's about understanding fundamental principles and seeing how they interact.

We've seen excellence: The Cell abstraction where causality, cohesion, abstraction, and constraints align perfectly to create elegant, understandable code.

We've seen pathology: The World god object where violations of cohesion cascade into coupling problems, which obscure causality, which hide temporal ordering, which prevent constraint enforcement.

We've seen how principles compound: Good decisions create virtuous cycles. Bad decisions create vicious ones.

Most importantly, we've developed a framework for evaluation that transcends any specific context. These principles aren't Java principles or OOP principles or functional principles. They're software principles - the physics that govern all computational systems.

Master these principles, and you won't need to memorize patterns. You'll see why patterns work and when they don't. You'll recognize problems at their root. You'll develop solutions that fit your specific context rather than blindly applying what worked elsewhere.

This is what software craftsmanship means: not knowing more patterns, but understanding more deeply. Not following more rules, but seeing more clearly. Not applying more techniques, but thinking more fundamentally.

The code is just code. The principles are eternal.


Acknowledgments

This analysis builds upon the Design Principles Dojo created by Ilias Bartolini. The dojo provides an excellent pedagogical foundation for exploring tensions and synergies between design principles through Conway's Game of Life. If you're interested in hands-on practice with these concepts, I highly recommend checking out the original repository and the accompanying facilitator guide.

The framework presented here - evaluating code through causality, boundaries, constraints, temporal ordering, coupling, cohesion, DRY, and abstractions - represents an evolution of thinking about software design principles in a more fundamental and unified way. Special thanks to the software craftsmanship community for continuously pushing the boundaries of how we think about and teach design.

This is the foundation of our Software Craftsmanship course at Stackshala.

We don't just teach patterns. We teach the principles that explain why patterns work or fail.

Students learn to:

  • Evaluate any design objectively
  • Diagnose problems at their root
  • Develop transferable intuition
  • Make decisions that compound positively

The result? Developers who can handle unfamiliar problems because they understand fundamental principles, not just familiar patterns.

Visit the course website to know more

Read more