On Class Design

This article goes into the specifics of designing a class such that we can achieve reasonable simplicity, readability and maintainability. In addition, this mechanism achieves desirable properties by working in a minimalist way, and ensures a lean-and-mean implementation. Note that a lot of what is described here is trivial and should be considered known to all developers, however in practice this isn’t the case. In many cases one cannot blame the individual for not knowing, because once you go down the wrong path you need to make compromise after compromise.

TL;DR

  1. Define invariants: Bi-directional dependency on data, or in exceptional cases uni-directional dependency on a subset of non-visible data.
  2. Define fields: Make access/visibility only as restrictive as needed.
  3. Define operations: Minimal amount of logic needed to make API suitable for public consumption, i.e. minimal amount of logic that preserves invariants.
    You are defining exactly those operations needed to make the class useful, i.e. it can do all it needs to given the state it manages, while the class itself remains sufficiently in control as to preserve the invariants.
  4. Profit! (i.e. enjoy the benefits)

Introduction

Many people I have personally talked with on this subject, myself included, struggle with objectively identifying what modification would be good or bad, i.e. beneficial or problematic for the class’ design, due to the complexity of existing code bases. Discussions often end up in personal preferences and aesthetically pleasing looks, where even the order of fields are valid for questioning, although it is semantically irrelevant and syntactically almost always insignificant.

I haven’t searched the internet or researched extensively on whether or not any of this is already researched somewhere. I do not expect to be the first one to come up with this. From my personal perspective, the only novel ideas are: 1. using the connected graph of bi-directional relationships as an indicator for data that should be put together in a class, and 2. using the strict boundary - as result from bi-directional relationship - as the qualifier for minimalistic approach.

The article remains agnostic of a specific programming language. The restricted, minimal design is the key here. How to apply that in your programming language is left as an exercise to the reader.

The method used is that of a minimal design that follows certain rules and relies on natural boundaries. These boundaries emerge from the functional requirements. It is not open to discussion where the boundary itself is. Any room for interpretation is in semantics, i.e. in the functional domain.

This is not to say that this is the only right way. However, foregoing on a minimal design will add “bloat” to your class. If you take a different approach, the primary question should be: do I want my complexity inside the class or outside? This mechanism pushes any additional complexity out, such that even as the application as a whole may be horrible, at least we can be in control of this class. Hence improving the application, one class at a time.

This article extends on an earlier post “Object Oriented Programming: Designing objects”. Unlike that article, we will go more into the rules instead of elaborating every detail.

Getting started

A short guide to illustrate what steps we need to take to design a new class or modify an existing class. This is an illustrative outline and may not work perfectly for all cases. It is more important to understand the intended goal and what it takes to get there, than it is to learn these steps.

Designing a new class

  1. Define invariants. If done correctly, there is very little overhead in doing this. This information is needed when writing all operations of the class, so the information should be known anyways. Forgetting invariants leads to state corruption due to your code not behaving as expected for all cases.
    Note that there is more risk in forgetting invariants after initial design than it is while designing the class initially. So, documenting the invariants is most important for future reference.
  2. Define fields for the class with sufficiently restrictive access modifiers.
  3. Write logic for operations together with any necessary utilities for logic using the class’ API only.

Modifying an existing class

  1. Retrieve or derive invariants that hold for this class.
  2. Identify the set of fields that have bi-directional data dependencies, given identified related invariants.
  3. Evaluate which operations are needed to preserve this set of invariants, given the set of fields.
  4. Move operations out that are irrelevant.
  5. Reduce logic in operations to minimal necessary logic for preserving invariants while providing needed API.
    • Reduce internal usage logic by replacing it with utilities.
    • Extract superfluous (usage) logic past the minimal design and make it separately available as utility for this class.

Definitions

First, let’s put down a few properties for what is allowed for fields and operations. Notice that I use operations in this article, to avoid the loaded words function and method. These concepts mean different things for different programming languages, if they exist at all.

Fields

Exception: uni-directional dependency on private state

There is an exception to the rule of allowing only bi-directional data dependencies: sometimes, a uni-directional dependency is all you need. However, this dependency depends on a field that must, under all circumstances, remain private in order to guarantee the invariants. In this case, there is no other option than to make this data dependency part of the internal state.

Utilities

A small interlude. Even though utilities are not actually part the class itself, they do play a important role in preserving simplicity and readability. We define the utility function. In the next section we refer to utilities as a way of reducing logic in operations.

Utility function: One or more lines of logic, possibly with some predefined literals, that can be packaged as a separate function which is agnostic of any specific business application, and stateless. Utility functions typically apply to a single type. Utility functions are “recipes” of usage logic for the given type. If common enough, they are made available as utilities.

Operations

Operations are the functions/methods that are provided as part of the class design. Publicly accessible operations are there to offer a prescribed set of ways of using the class.

A set of operations is defined which each have the minimal amount of logic to accomplish the desired state mutations while preserving invariants. Given the minimalist design, there should only be a limited set of viable operations. Getters and setters are less valuable, as these manipulate only a single field, which is of very limited use given that dependencies exist between fields. Rather, you would see an operation that performs a particular set of mutations on a number of fields. It changes the data hence changing what the data represents. An appropriate name is chosen for this operation, where the name reflects the mutations being performed. These mutations are typically non-trivial, hence there is relatively little need for trivial getters and setters.

Note that we mentioned before that we require the logic be as minimal as possible. This is due to the fact that the logic should only look inward to the state, i.e. the class’ fields. The instance’s context is of no concern. This is the concern of the caller of the operation. The operation’s arguments are the exception in so far as that it enables the caller to hand out additional information for parameterized execution.

Properties

There are a number of properties that hold for classes designed with this method. We will discuss these. Some of the properties have no strict definition. Examples of such properties are “single responsibility” and “simplicity”. In the corresponding sections I try to illustrate how I (informally) define the property, followed by why it qualifies with this method of class design.

Single responsibility

Single responsibility is certainly in the top-3 of “desirable properties” for a good design. We consider the “single responsibility” property met due to the fact that we select the minimal selection of fields, namely those with a bi-directional dependency. It is minimal since we cannot break this dependency and still hope to preserve invariants under all circumstances. Whatever modification we wish to make to the state, due to the bidirectional dependencies, we are guaranteed that the modification is relevant to all fields.

The same holds for the operations. We require operations to contain the minimal necessary logic and for each operation to be concerned only with mutating state. Hence an operation’s focus is solely on performing mutation while respecting and preserving invariants.

Consequently, due to the minimal nature of the design, there is only a single concern, a single responsibility.

High cohesion, low coupling

As we select specifically for data with bi-directional dependencies, we implicitly select for high cohesion. We design the class to only include state that we cannot otherwise separate.

We would like to ensure low coupling too, but so far I can only reason about this by proxy. I will assume that coupling increases with bad design, i.e. multiple concerns together in a single class, meaning that the class has more than a single responsibility. Why is that? Well, if there are multiple concerns and each of these concerns has dependency (uni-directional) dependencies to outside or from outside coming in, then the amount of coupling grows quickly. On the other hand, if cohesion is high and single responsibility is respected, then we should need relatively few dependencies to the outside or vice-versa. Hence coupling will be low.

In addition, ensuring minimal logic in operations, reduces the surface area for dependencies to other classes. Ensuring that logic only focuses inward, i.e. only seeks to modify the class’ state, makes it such that dependencies that might be required will likely be related to the (types of) fields in use.

Tell don’t ask

Tell don’t ask” is one of these principles that attempt to guide the developer in defining the right kind of operations on the class. It attempts to prevent the mistake of defining an excessive number of getters and setters where they are not appropriate. Although the getters and setters themselves are not an issue per sé, they do invite the caller of the instance to do the maintenance themselves, because they are able to access each individual field. The knowledge of the invariants, however, should be maintained within the class, not expected to be managed by its users.

The method described here, if applied correctly, should deliver this principle for free. Due to the requirement for minimalism, you already capture each set of state mutations in an operation with an appropriate name that reflects the nature of the operation. The emphasis is on the set of state mutations, as we expect to rarely have only a single field to mutate. The name of the operation should reflect this, hence no/few setters. Similarly, acquiring the raw value of a single field without its accompanying fields should prove to have little meaning, hence no/few getters.

Tell” becomes the norm, as opposed to “Ask”.

Encapsulation

The extent of access restrictions is a direct consequence of the requirement to respect and preserve invariants. Given few and lenient invariants, we can afford few restrictions. However, if the bi-directional dependencies are many or complex, the necessary logic to preserve the invariants complicated, then we shall be forced to be very restrictive to maintain control.

Simplicity

The minimalistic nature of this method is what achieves simplicity. Fewer invariants leads to fewer fields. Fewer fields leads to less logic to maintain for each operation. Fewer operations, as consequence of the single concern of the class, results in less logic to maintain. All of these reduce complexity, makes classes more comprehensible, easier to read, and easier to use. Furthermore, by splitting off usage logic from the minimally required implementation logic, we stimulate reusability. By reusing existing utilities in implementation logic, we ensure fewest lines of logic to realize the operation, hence improving both reusability and readability.

Levels of usage complexity

  1. Every public method can be called at any time.
  2. There is a certain “user guide” (knowledge) to how the object must be used.
    For example: we need to call operation a before operation b and operation c may only be called 2 times and only after b is called 3 times and there is a full moon.
    If expected usage patterns are not followed to the letter, you end up with runtime exceptions. You cannot call just any method at any time.

The 2nd case is typically an example that the class design leaves room for improvement. Most likely, many concerns are mixed up in a single class.

Levels of implementation complexity

  1. Logic that keeps the invariant in sync.
  2. Logic that, in addition to the above, needs to verify the state to ensure that public operations can only be called at the appropriate time.

The 2nd case is typically an example of bad design that is attempted to be fixed in the implementation of individual operations. Although this might work at first, the difficulty is in maintainability. One needs to remember all these exceptional cases and consider them with every change that needs to be applied.

All of these already assume that invariants are being respected and preserved, which in itself is also not a guarantee. The minimalist method helps to avoid these complex situations, such that the 2nd cases do not arise.

Benefits

There are a number of benefits for this over other proposed approaches:

  1. The rules are based on deterministic properties. As a result:
    • The rules are strict, as opposed to heuristics that try to approach a perceived ideal, with lots of room for interpretation.
    • Any room left for interpretation stems from issues with the invariants: either incorrect, confusing, or missing altogether.
  2. The rules work equally well with existing code as when designing a class from scratch. (Although applying to existing code takes more effort.)
  3. The rules, when applied to an existing class, may function as a way to identify technical debt:
    • redesign (reducing technical debt), identified by fields not strictly following rule of bi-directionality.
      This may lead to significant change, such as splitting up classes and extracting/moving significant amounts of logic.
    • API changes, identified by superfluous logic past the minimal necessary amount in the API to satisfy invariants.
      Consider what the minimal public API is. Extract any logic that is superfluous to this minimal API. Move this into a utility function.
    • code clean-ups, e.g. insufficient use of utilities:
      Leads to extraction of pieces of utility logic to put in functions or to simply replace with existing utilities.
  4. Reducing operations logic using (existing) utility functions raises level of semantic density. By using as many utility functions as possible, we do not only reuse code, but we also raise the average expressivity of a single line of code of an operation’s implementation. This improves readability, maintainability, reduces lines of code for each operation and thus class as a whole.
  5. Selecting for the minimal set of necessary operations (part of API):
    1. ensures that the class design stays comprehensible. Keeps complexity as low as possible.
    2. prevents an explosion of operations, API.
    3. obsoletes discussions on whether a certain operation should be implemented as part of the class or not.

Trade-offs

The method described in this article takes an approach to design a minimal class. Of course, other designs are not necessarily bad. They may have their own benefits. We want to argue here that if you choose to take a different approach, that this opens the door to more risk, complexity, etc., while not providing significant improvements.

  1. Adding more fields to the state, disregarding the requirement for bi-directional dependency:
    • Increases number of invariants to take into account.
    • Adds complexity in API and logic.
    • Makes maintainability and readability more difficult, due to more information to take into account.
  2. The opposite extreme is putting all fields (all data) in a single class. Although the extreme itself does not often occur, code does tend to move towards this extreme, due to new fields being introduced incorrectly into a class, thus unecessarily complicating its design.
    As mentioned, this is a sliding scale. It is important to realise that with frequent modification one tends to slide towards the bad extreme. And there is little indication of how bad it is or whether refactoring/redesigning is necessary. While this article advocates working with the minimal boundary, frequent modifications tend to move you towards the maximal boundary.

FAQ

Future work

Changelog


This post is part of the Living documents series.
Other posts in this series: