Observability, Testability, and Encapsulation – Opposing Tensions Shape Good Design

There is so much to learn to become a good software developer. Once you get past the technical hurdle of mastering all the tools, frameworks, and languages you need to just make something work, there is still a whole world of system design and engineering practices to learn. Much of this material is not as straightforward. Everything has tradeoffs, and the right answer for how to design something is often ‘it depends’. You will find differing opinions about the best way to do things. Many of the concepts and goals for building good systems can seem to be in opposition to each other. Encapsulation is a software engineering principle that encourages you to hide details. Observability and Testability are software engineering principles that encourage you to expose details. But that opposition is not a bad thing. Opposing forces lead to equilibrium, and that is a wonderful place to be. Let’s take a look at how the combination of these practices can help us build better systems.

What is Observability?

In short, the ability to see what your system is doing. This can take many forms. It may be the ability to query your system’s current state. A basic example is adding a health check endpoint to a server to report if it is on and responsive. It may be tracking performance data, like adding tracing to your http calls so you can see how much time the system spends doing particular operations.

When a system is observable, it is easier to operate. We can diagnose problems quickly. We can analyze its performance and use that data to improve it.

What is Testability?

A system is testable when you can verify its behaviors. A ‘very testable’ system makes it easy to verify all behaviors. A ‘hard to test’ system makes it hard to verify all its behaviors.

An example of a ‘very testable’ system is a pure function. It takes some parameters, it returns something.

// A 'very testable' system
function add(a, b) { return a + b }

// test it!
const result = add(1, 2);
expect(result).toEqual(3);

More complex structures can be very testable.

// A 'very testable', but more complex, system
// A FIFO queueing system to pair players up for a 1v1 multiplayer game
class Matchmaker {
  private IdGenerator _idGenerator;
  private String _waitingPlayerId;

  public Matchmaker(IdGenerator idGenerator) {
    _idGenerator = idGenerator;
  }
  
  public Optional<Match> findMatch(String playerId) {
    if (_waitingPlayerId == null) {
      _waitingPlayerId = playerId;
      return Optional.empty();
    } else {
      Match match = new Match(
        _idGenerator.newId(),
        _waitingPlayerId,
        playerId,
      );
      _waitingPlayerId = null;
      return Optional.of(match);
    }
  }

  public String getQueuedPlayerId() {
    return _waitingPlayerId;
  }
}

// test it!
@Test
public void testFindMatchWithNoPlayerWaiting() {
  // Given
  IdGenerator idGenerator = new AutoIncrementingIdGenerator();
  Matchmaker matchmaker = new Matchmaker(idGenerator);
  assertEquals(null, matchmaker.getQueuedPlayerId());

  // When
  Optional<Match> result = matchmaker.findMatch("player-1");

  // Then
  assertTrue(result.isEmpty());
  assertEquals("player-1", matchmaker.getQueuedPlayerId());
}

@Test
public void testFindMatchWithPlayerWaiting() {
  // Given
  IdGenerator idGenerator = new AutoIncrementingIdGenerator();
  Matchmaker matchmaker = new Matchmaker(idGenerator);
  matchmaker.findMatch("player-1");
  assertEquals("player-1", matchmaker.getQueuedPlayerId());

  // When
  Optional<Match> result = matchmaker.findMatch("player-2");

  // Then
  Match expectedMatch = new Match("0", "player-1", "player-2");
  assertEquals(expectedMatch, result.get());
  assertEquals(null, matchmaker.getQueuedPlayerId());
}

The Matchmaker has a dependency on another type, IdGenerator. IdGenerator may produce random output, since it is most like going to create new UUIDs. It also has internal, mutable state. Despite these additional complications, we can still structure the code so that we can test all the behaviors. We can dependency inject an instance of IdGenerator so that we can supply an implementation that produces predictable IDs. We can add a method to query the current state of the queue.

An example of a ‘hard to test’ system is an object which performs mutations or relies on unpredictable or uncontrollable inputs.

// good luck testing this
class ComplexThing {
  private String status;

  public void doSomething() {
    ApiResponse response = MyApi.createSomething(UUID.randomUUID());
    if (response.failed) {
      this.status = "error";
    } else if (response.duration > 5000) {
      this.status = "slow";
    } else {
      this.status = "good";
    }
  }
}

This code depends on a static call to an API and a static call to get a random UUID. We can’t reliably control what the API returns, so we can’t reliably test how our code handles different kinds of responses. We can’t reliably control the UUIDs that get generated, so we may not even be able to query the API in the test to find out what happened. We can’t assert on the status without resorting to reflection, because it isn’t exposed to client code in any way.

When a system is testable, we can build and maintain it more easily. We can verify that it behaves how we expect in a variety of situations.

What is Encapsulation?

Encapsulation is about hiding implementation details behind an interface. Good encapsulation maximizes the functionality you get, while minimizing the knowledge burden to use it. A car steering wheel is a classic non-software example. As a driver, you don’t need to know the complexities of how the steering wheel connects to the various mechanisms and ultimately to the wheels. You just need to know that turning it right makes the car go right, and turning it left makes the car go left.

Encapsulation offers two key benefits to software developers working on the system. First, it reduces the knowledge burden of understanding the thing, which means developers with other responsibilities can focus on other responsibilities, instead of needing to become experts on a poorly encapsulated module. For example, a well encapsulated recommendation system might allow developers to call it like so:

val listOfMovieIds = recommendationSystem.recommendMovies(userId)

A developer using this recommendationSystem doesn’t need to know any details about how it works, they just need to know that if they pass a userId, they will get back a list of movie of IDs. Then they can go on about their business doing their job.

The second key benefit is that it makes the system easier to change over time. Good encapsulation means that different modules know less about each other. They are loosely coupled. The contact points are fewer and simpler. You can change the implementation details of a well encapsulated module with minimal or no changes to other parts of the system. You can easily rearrange the system and plug a module in elsewhere.

LIke testability, when a system has good encapsulation, we can build and maintain it more easily.

How do they interact?

There is some opposing tension between the forces of Observability and Testability on one side, and Encapsulation on the other.

Encapsulation encourages us to hide details and have a smaller interface. Testability encourages us to expose enough detail so that we can verify behaviors. Observability encourages us to expose enough detail so that we can understand what our system is doing. At one extreme, we encapsulate so much that we can’t verify the module performs as expected, and we can’t analyze or optimize its performance. That is bad. At the other extreme, we expose so much, that anyone who wants to use the module has to learn all of its details. Those details may end up littered throughout the system because they are easy to get to, making our system harder to change. That is also bad.

We need balance. Developers want to curl up in a comfy interface that is neither too small, nor too big, but just right.

But how do we achieve that? Constant vigilance as you iteratively develop the code. Test Driven Development will ensure you expose at least enough about your module to make it testable. Programming to an interface will help keep things encapsulated. Interfaces intentionally hide implementation details. When you focus on the behaviors of your system from the perspective of a caller, it leads you to think along the right lines to help you get that ‘just right’ balance. After you have written the tests, you can see which methods and data were necessary to verify the behavior of the system, and which were not. Remove everything you didn’t need from the interface. You may also spot some clean ways to refactor to hide more implementation details without losing any of the observability you need to verify your system’s behaviors. Remember, your system is not your code. Your system is the observable behaviors that it exhibits.

Conclusion

Building systems is hard. There are many ways to do it, and there are no right answers. A big part of building better systems boils down to experience – build as much as you can. Sometimes you just need to be consciously aware of what is happening in order to do it better. You can’t make a system more testable if you aren’t thinking about testability. You can’t make a system more observable if you aren’t thinking about observability. You can’t improve the encapsulation of your modules if you aren’t thinking about encapsulation. Be mindful of these concerns in your code and go build something great!