On Class Design
Thu, Dec 5, 2019 ❝On the design of classes for simplicity, readability and maintainability.❞Contents
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
- Define invariants: Bi-directional dependency on data, or in exceptional cases uni-directional dependency on a subset of non-visible data.
- Define fields: Make access/visibility only as restrictive as needed.
- 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. - 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
- 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. - Define fields for the class with sufficiently restrictive access modifiers.
- Write logic for operations together with any necessary utilities for logic using the class’ API only.
Modifying an existing class
- Retrieve or derive invariants that hold for this class.
- Identify the set of fields that have bi-directional data dependencies, given identified related invariants.
- Evaluate which operations are needed to preserve this set of invariants, given the set of fields.
- Move operations out that are irrelevant.
- 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.
- Invariants: The rules that operations (implementation logic) must respect when modifying state. This ensures that the class instance always behaves correctly when used by the caller. The caller must not be allowed to do anything that would corrupt the instance’s state.
- Should define relationship between two (or more) fields. By transitivity, any bi-directionality between 2 fields, extends to all other fields. (Invariants make the bi-directional relationship explicit as requirements.)
- Prefer a uni-directional dependency if possible. Only if bi-directional is the only option to make it work, should it be used. Although this should be obvious, I must emphasize that we need to restrict the content of a class, so only strictly necessary fields should be allowed in.
- The invariants must be mutually cooperative, i.e. no conflicts. (Otherwise you wouldn’t be able to make your class logic work.)
- State: One or more fields that together represent the concept that the class represents.
- Each data field must have a bi-directional dependency with another field in the state. By transitivity, each data field has a bi-directional data dependency with all other fields in the same state.
- Given the bi-directional dependencies, fields in the state must be kept in sync with other fields in the same state, due to invariants.
- Operations: Any state modifications that are available. Operations make changes to the state while respecting the invariants.
- Operations consist of a minimal amount of logic to offer a set of prescribed state modifications while preserving invariants. (This logic is the materialization of the invariants.)
- Each operation ensures that all invariants are preserved at end of execution.
Fields
- Public field: Fields that have no access restrictions. These are typically not useful as they could be used as an individual variable, i.e. there is no significant dependency if the user can modify it in any way possible. There may be a few exceptional situations like a class representing a table row, such that there is no strict bi-directional dependency. However, that’s already stretching the definition.
The exception would befinal
fields. These are available for accessing but not for assignment. Consequently, this field is ensured to exist. This works for assigning calling dependencies at construction-time, which are guaranteed to be available whenever the control flow reaches for the field. As long as the containing class only requires availability, i.e. does not expect a specific state at any time, then we afford to make the field publicly accessible. - Protected field: As with public fields, however now restricted to the API of the class hierarchy, or more precisely the extending classes.
- Private field: Any data that is part of an invariant, i.e. with dependency to other data. Given that the data is private, access can be tightly controlled through operations. The field content may not be directly exposed at all, but instead only used in internal state modifications as part of operations.
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.
- Public operation: Part of the API, i.e. the available actions for the class. Public methods must ensure that invariants are preserved at the end of each individual call.
- Protected operation: Same as public, but represents API for your derived classes in the hierarchy only. Protected operations must preserve invariants in the same way that public operations do. The difference is that protected operations are the API that is only available to derived classes in the hierarchy.
- Private operation: Shared logic that requires access to private resources (operations or state) and therefore must be implemented as part of the class. This logic should be reduced by its utility logic. Private methods do not have to preserve invariants, since they can only be used from protected or public operations which already meet this requirement.
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
- Every public method can be called at any time.
- There is a certain “user guide” (knowledge) to how the object must be used.
For example: we need to call operationa
before operationb
and operationc
may only be called 2 times and only afterb
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
- Logic that keeps the invariant in sync.
- 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:
- 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.
- The rules work equally well with existing code as when designing a class from scratch. (Although applying to existing code takes more effort.)
- 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.
- redesign (reducing technical debt), identified by fields not strictly following rule of bi-directionality.
- 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.
- Selecting for the minimal set of necessary operations (part of API):
- ensures that the class design stays comprehensible. Keeps complexity as low as possible.
- prevents an explosion of operations, API.
- 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.
- 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.
- 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.
Intuition
For those of you who prefer a more intuitive understanding of this topic.
By following the defined constraints strictly, you are effectively squeezing any application state out of individual objects. That is, only variables with high cohesion - that are inseperable - will remain in a class. Any variables that are part of the application’s state, will be pushed upward in the application structure.
FAQ
- How to determine if the addition of a field fits with the class design?
Define your invariants. Check if there is a bi-directional dependency to existing state in the class. - Choose a name that suits the class, instead of design that suits the name.
The class’ name is an approximate description of the class’ intention. Do not let yourself be guided by a potentially misguided name as the lead argument for extending a class. Instead, check invariants, check bi-directional dependency. (These are factual, and above all, these are reality for this class design.) - Can’t we just automate everything if the rules are this strict and deterministic?
In theory: yes. In practice: you might end up with so many small classes that you really aren’t going to be happy. Consider that your “exhaustive transformation into good code” would be based on a single momentary snapshot. Most applications are still evolving and therefore the snapshot state may not be good. In addition, inconvenient choices of abstractions, excessive manipulation of fields, etc. all tend to give a false impression of what is really relevant in a class. The classes themselves are typically far from minimal. So, yes, you could automate parts, but without the designer evaluating the current state, it may produce far from the ideal end result.
Future work
- Class design evaluation: “Extraction of invariants from logic”
Given a set of fields and operations, extract bi-directional dependency rules from the logic. Present these as human-readable rules. That would be the result. The person receiving the result can then annotate or transform the mechanical rules to functional requirements using domain concepts. - Class design evaluation: “distance from minimal”
It would be interesting to see if we can create a sort of automated evaluation of a single class design, based on the method described in this article. You would be able to “grade” design by a metric such as “distance from minimal” given current fields and logic.
Changelog
- 2019-12-05 Added intuitive understanding in new section Intuition.
- 2019-06-30 Split off first paragraph as sort-of “abstract”. Introduce ‘TL;DR’ section for non-readers.
- 2019-06-23 Explain exception for uni-directional data if dependent on private field.
- 2019-06-21 Add property subsection for “high cohesion, low coupling”.
- 2019-06-19 Initial version of article.