Engineering: The “minimal-objects” approach to OOP
Tue, Mar 1, 2022 ❝Understanding the role of simplicity in object design (Object-Oriented Programming)❞Contents
A few years ago, I looked into Object Oriented Programming and in particular object orientation – the paradigm – in a series of articles that look into Object Oriented Programming. These articles look primarily at the use of OOP in programming, identifying and discussing key points on how OOP works, should work, and deviations from how it is typically used. This focuses on the individual class, the way it is designed and implemented, the characteristics, and the benefits you get from doing it right vs. the issues when doing it wrong.
In a previous series, we looked at simplicity. The idea to look into this came from the endless quotes and advice on “keeping things simple”, although nobody quite tells you what “simple” actually is.
TL;DR this post is long. For a quick first impression you can best read Minimal-objects and invariants (without remarks), Fast and efficient development and Conclusion.
Abstract
In this article, we will look at how the minimal-objects implementations offer actual simplicity in OOP. We will discuss the rules that lead to minimal classes and their benefits, and compare them against many of the guidelines and best-practices that already exist. This article is part of a larger effort of explaining OOP in terms of a notion of simplicity/complexity. This article touches on small-scale implementation details mostly, as this was how this exploration effort started. It touches on design matters tangentially, as design is the main topic for the next article of the series.
Minimal-objects works for any programming language that satisfies the notions of encapsulation and to a lesser extent polymorphism. Encapsulation exists as the primary characteristic in many of the OOP programming languages in widespread use today. Concurrency is irrelevant for this article and will be discussed extensively in a later article.
Minimalism itself is not the goal. This is not about minimizing the number of classes or minimizing the number of lines of code, or any other raw numbers. Instead, the goal is about minimizing logic at certain places, such that – in general – logic ends up in more (most) beneficial places.
Note that this post (probably) offers little novel information, if individual paragraphs are considered. Instead it runs with a single idea: a notion of “minimal-objects implementations”, guided by a concrete notion of simplicity. We look at many of the existing (idealized) properties of “good code” and try to unify them, i.e. to see what properties hold without conflict or contradiction, and with meaningful semantics.
Terminology
We will discuss some terms to align their meaning.
“expansion/reduction”, “generalization/specialization”, “optimization”
These terms are used in the way they are described in the article on defining simplicity. Their intuitive meaning should be clear regardless, but they may be used with very specific intention.
“class”
The term class, unless stated otherwise, merely refers to a composite structure. If we mean a class-/type-hierarchy, this is mentioned explicitly. The distinction is important because hierarchical structure is not a precondition for this article.
“use” and “privilege” (access restriction)
The article uses the terms “usage” and “privileged” to indicate whether a field or method is accessible from anywhere ("usage", accessible for use) or whether it is only accessible to other members of the same type ("privileged", only accessible with the privilege of being a member of that type). The distinction is such that accessors either share concerns and responsibilities, i.e. class members, or they do not.
Programming languages often have more access modifiers, e.g. private
, default
/package-private
, protected
, public
. However, these are of no concern here, as the significance is in whether outside tampering is possible, i.e. is a risk. A package-private
-accessible method is basically public access (use) for a smaller scope.
My previous article on this gives more details, but what is immediately relevant has been described.
Minimal-objects and invariants
Minimal-objects is a (temporary) name indicating a guideline to defining (OOP-style) classes that do the very minimum to satisfy their own (singlular) concern. In order to satisfy this concern, a number of invariants must hold. The invariants ensure that the concern is represented correctly, i.e. the data is in a consistent state after every use.
Before getting into the rules, we need to be clear on the scope to avoid getting entangled into issues that are irrelevant to the class. A strict separation of implementation (details) and use is important.
The only responsibility of a class is its own implementation.
The following rules help to define the class in minimal-objects style.
-
All fields are bi-directionally dependent
Each field must be necessary for the concern to be represented: if any one field is removed (no dependence) or separated (uni-directional dependence), it is no longer possible to satisfy invariants for all cases. That is, all fields are strictly necessary and cannot be otherwise accessed, e.g. through partitioning a subset of fields into an encapsulated type, e.g. a class. -
Every method is necessary (minimal number of methods)
Every method requires privileged access, accessing a member. -
Every method is minimal (minimal amount of logic)
Every method contains only the logic necessary to modify data. In addition, every use-accessible method subsequently restores the class to a valid, consistent state, i.e. a state where all invariants hold. -
Expose API as necessary to represent the concept
A class exposes members as necessary as part of the represented concept. This includes constants, fields, methods, parameters and return types. Anything that is accessible for use is part of the class’ API. Method parameters define the requirements needed for use, and return-types express the guarantees on the result. In both cases they should include only what is relevant. Carefully consider what you expect of parameters, and what guarantees you provide for the result. -
Restrict access sufficiently to prevent tampering
A class must be able to enforce its invariants. It should be impossible for a class to reach an inconsistent state (invariants violated) from its use. Fields and methods therefore need to be guarded. This is done through restricting access to the extent necessary, as well as allowing mutability only when necessary.
subclassing the same rules apply. There is still the distinction between the class’ internal concerns and its use. In case of a subclass, its parent is used as a part of managing itself. The parent should still guard and ensure its own invariants toward its subclasses.
abstract definitions the same rules apply. Abstract definitions, such as interfaces or traits, do not have implementation logic, but the same considerations hold for its member(s).
This selection of rules is small, because its focus is on applying simplicity to object (OOP) implementations, i.e. minimal-objects. These rules are not sufficient to explain OOP in its totality. In a later section, we look at guidelines and best-practices clarified using these rules.
An added benefit is that these rules are designed to use “natural” properties of the programming language as “anchor point” for the boundaries. It still requires thinking, but this time about the representation itself.
Remarks
These remarks help to clarify the rules by emphasizing the various implicit consequences and to elaborate on different cases and other circumstances. The rules stated above are defining. The remarks help to give context and to illustrate rationale.
-
Regarding the remark of: “immutable field implies modification is impossible”. This holds for – for example – Rust, as Rust manages access (unless otherwise specified) at the level of a region of memory as a whole.
However, it does not hold for Java, as instances are references to individual regions of heap memory. So, afinal
modifier in Java would ensure the object cannot be swapped out for another, but it cannot prevent that inside the object mutability is allowed. -
Some types of optimization will perform redundant data management such that derived data is trivially available. For the analysis, where we evaluate whether the fields are the minimal selection, it does not hold for the optimization case. One can trivially see that some derived properties can also be derived – as opposed to persisted – and therefore do not need to be stored. However, given that “minimal-objects” follow the definition of simplicity, and optimization is a dimension of simplicity/complexity, it means that simplicity no longer holds. A choice is made: to prioritize optimization over simplicity.
-
Minimal logic ensures that the methods do not dictate the way in which the class is “supposed to be” used. The only responsibility of the class is to itself: manage its state as defined by its invariants.
-
Minimal logic in methods leaves maximum room for reuse of logic in utilities, design patterns, etc. (Or in other, better suited locations.)
-
An edge case of the minimal-methods and minimal-logic rules is that some methods can be implemented as utilities that make use of another method. The rule that requires privileged access validates the existence of the method.
As an example: let us say you are implementing an arbitrary-length integer class. If you implement addition as a method, you technically have everything to implement multiplication as a calculated series of additions, therefore multiplication classifies as utility logic. However, this does not take into account more efficient options if implemented as a method using internal fields to directly manipulate the representation of the integer. The justification comes from the efficiency gained through privileged access. (Ideally, efficiency improvements that are distinguishable by Big-O notation.) -
In this article we only distinguish between accessible for use and privileged access only (the latter meaning: only accessible from within the same class, i.e. logic concerned with the same invariants). Programming languages may have more levels of granularity for access. Intermediate levels, such as
protected
, can be considered accessible for use, just that the usage-scope is more restricted. The determining factor is whether access is restricted to the hosting class, or to “users”. The class has its own invariants to be concerned about, while the user of the class is only interested in using. -
One rule states a class’ members are exposed as necessary for its representation. Another rule states restrict sufficiently to prevent tampering. Note that these rules have a necessity/sufficiency relationship.
Choose types such that they disentangle/decouple the class from its users. Interfaces are often useful: they express requirements without tying the logic to a particular implementation. Types with fewer requirements in parameters are easier to satisfy. Domain-types are often safe as these have no further dependencies. Return-types with fewer guarantees leave room for internal changes without violating these guarantees. -
The rule that restricts access to prevent tampering implicitly allows use-accessible fields, e.g. because immutability prevents tampering. Even if not promoted by programming language-specific best-practices, this may very well be reasonable.
-
Java has a convention to make all fields private, and then expose them through “getter”- and “setter"-methods, even if these methods contribute nothing towards satisfying the invariants, i.e. are semantically redundant. This is not strictly necessary, as long as tampering is impossible. It is important if you do not have a well-defined class, as it would change. If rules/invariants are then introduced that restrict a field already exposed for use, it would require a change to the API and thus break the parts of the application using this class. Additionally, not all fields need to be exposed: only expose fields if accessing these fields is meaningful to the concept, i.e. necessary for proper use.
-
You do not define an interface for exactly one implementation. You either define an interface because the implementation thereof is out-of-scope, or because there are multiple candidates. The interface simply states: this is what is expected, such that it can interoperate.
-
There are some concerns that can be represented without data. This is sometimes the case when an interface is implemented. Any “knowledge” that is inherent to the interface is hard-coded in the implementation, i.e. in constants or logic. For example, the logic for comparing a particular type of value.
-
Logic that is not part of the method itself, i.e. does not satisfy the rule for minimal logic, may be candidate for utility logic: a sequence of usage logic for one particular type.
-
Abstract definitions that are minimal, have the added benefit of making it easier to be considerate of the API, i.e. be aware of what you expose and its requirements and guarantees. A simpler interface is both easier to satisfy and to use.
How to evaluate
Now that we have set some rules, with clarifications where needed, we will evaluate many guidelines and best-practices of software engineering based on simplicity and the “minimal-objects” rules.
- Rules and best-practices cannot contradict the rules of simplicity: minimizing each dimension.
- Minimal-objects rules must be applied consistently: explanations cannot contradict each-other.
note this rule must take into account the distinction between internals and API - Boundaries cannot be based on arbitrary numbers. (“natural boundaries”)
Properties, guidelines, best-practices
Although there is no standard, uncontested way of programming, there are quite a few pieces of advice in the form of guidelines and best-practices. In this section we will discuss these individually to see how these properties evaluate against minimal-objects. No further rules are introduced. Note that the scope of minimal-objects is a single class.
Please note that this section assumes some familiarity with these concepts. Otherwise, these explanations will be far from sufficient. You can find more information on any of these guidelines. However, be considerate when reading, because for these guidelines too, there often exist multiple explanations.
-
Use concrete types internally: Each class is its own little program, as intended by encapsulation. There is no point in generalizing types that only exist internally, as class logic itself must already be aware of implementation details. Even more, hiding such details may obscure how the type can be used more efficiently.
-
Class responsibility: Every class is only responsible for managing its own (internal) state according to their concern, i.e. whatever it is they represent. “You only look inwards, not to your direct/indirect surroundings.” How a class is used, whether it is for the right purpose, is a concern of its user. But, whichever way the class is used, its use cannot result in its state becoming inconsistent. Inconsistent state, despite it being guarded, would indicate that there is a bug.
-
Single-responsibility principle (SRP)/Single concern: Every class represents some kind of concept: a domain-type or a particular interface that is implemented. This concept is according to a certain functional domain, a domain-type or an interface they implement, or both. Its state represents this concept and the logic enforces its invariants.
-
Composition over inheritance: The idea of “composition over inheritance” is a well-known guideline that is natural to minimal-objects. Minimal-objects prescribes that classes are only as large as strictly necessary, i.e. no further reduction is possible. This ensures that the class’ size in terms of number of fields, methods and general complication, cannot get out of hand.
Furthermore, minimal-objects prescribes that if you find a subset of fields that it can be encapsulated in its own class, and subsequently used in the other class: composition. Note that the field should be the primary indicator as methods can easily outgrow and have multiple purposes.
As the class-types in use are themselves reliable, trustworthy units, they do not require verifying by the using class: each class enforces proper use by itself. A class that is composed out of other classes, needs only to verify its own behavior, its use of other classes and primitive types.
Preferring composition over inheritance does not negate the value of class-hierarchies or the inheritance-mechanism. Instead, it simply points out that composition is preferred. The preference for composition is based on the idea the usage-level access is sufficient for most cases. Inheritance, subclassing, derivations and other such alternatives are the go-to mechanism when composition cannot offer the solution. -
“Tell don’t ask”: If there is indeed the minimal amount of logic, as prescribed by the rules, then this is not an issue. Literally, there is no room for “asking” for something. The class offers methods for anything that needs to be guarded, because there is a risk of violating invariants. Otherwise raw field access could have been provided. The collection of methods offer all the ways in which the object can be manipulated. Because they are minimal, methods have straight-forward effects. Any method you call, is telling that class to manipulate the data in that particular way, and nothing more. How the class is used, is up to the user.
-
High cohesion: The fields are selected because they are the minimum necessary to represent the concept. It is not possible to further separate or partition them. Therefore they are atomic. This inseparable unit is the highest cohesion you can achieve. The methods are the minimum necessary for the class to be used while preserving state and invariants. Each method contains the minimum amount of logic necessary: to manipulate the data and subsequently ensure invariants are restored.
-
Loose coupling: Coupling concerns itself with an amount of entanglement of code, such that it becomes hard, if not impossible, to separate. The minimalism rules ensure that classes remain small. Utilities help to make things expressive with the most basic mechanisms available, while suitable.
Coupling consists of a number of related metrics that each contribute to the over-all degree. Some of these are attributed to the over-all design, so coupling is only partly discussed. -
Design patterns are now more practical and beneficial. Given that each class provides the minimum necessary API, the API itself is unopinionated. Design patterns exist for various purposes, e.g. an adapter can now be used to bend the use to the use case. Due to the API being minimal and unopinionated, the adapter merely has to use the class in the desired way, ensuring (relative) ease of implementation, rather than bending the class over backwards to force it into the desired use case. The “preferred opinion for this one use case” becomes the concern (of this one instance) of the design pattern.
-
Optimization is about descending a level of abstraction in order to fine-tune an implementation with control that are of a level of detail not attainable at higher levels of abstraction. Hence descending into this lower level. Optimization is typically considered late, if not last, in the list of possible improvements, because other options, such as a careful selection of algorithms, process steps, step order, etc. are all more easily applied and generally more effective. (see simplicity)
- premature if optimizations are applied before other options are exhausted. Premature because other options are cheaper, both in cost of application and of adoption costs/negative side-effects.
-
Generalization/specialization is about widening the range of applications. The more general a solution is, the broader it can be used. However, with broad application comes limitation as the generalization has to satisfy the API shared between all types. This could mean that the most efficient sequence of functions is not available. (see simplicity)
- premature if generalization is applied before it is necessary. There is a general rule to apply generalization only after a third use case is known. This is especially effective for type-parameterization (generics).
-
Expansion/reduction is about the number of “moving parts” and “rules” that exist. The least amount keeps things easiest to understand. For example, the least number of fields in the state, the least number of constraints on single fields or between fields. Essentially, the number of invariants to guarantee and how complicated they are. (see simplicity)
- premature if expanded without need. Common practice is in extending the class’ functionality before considering other options such as composition or applying design patterns to adapt or wrap/decorate the class.
note a common mistake is to repeatedly extend a class with a small amount of logic and a few fields. Then, after a few iterations and some time away, revisit the class and realize that you no longer understand how everything fits together. It has effectively become many classes merged into one.
- premature if expanded without need. Common practice is in extending the class’ functionality before considering other options such as composition or applying design patterns to adapt or wrap/decorate the class.
-
Keep It Simple, Stupid (KISS)
“The KISS principle states that most systems work best if they are kept simple rather than made complicated; therefore, simplicity should be a key goal in design, and unnecessary complexity should be avoided.” – Wikipedia: KISS principle
What this actually means depends very much on what one means with “simple”. If we assume the definition from a previous post, it is composed of three orthogonal dimensions: optimization, generalization/specialization and expansion/reduction. With simplicity itself being the minimum for all three axes. KISS keeps things simple by minimizing moving parts and prescribed rules, minimizing breadth of applicability to what is actually needed, and preventing unnecessary (low-level) optimization.
-
“Don’t Repeat Yourself”: given a minimal class, typically usage patterns will emerge as recurring sequences of logic. These common patterns can be captured in utilities. As the class is minimal, there is a high likelihood for these patterns to emerge, and as they are usage patterns, it is reasonable that such a pattern will occur more than once.
Utilities – trivial functions or more complicated constructions – offer easy access to common usage patterns. For any sequence of usage logic that you turn into a utility, consider what the most general type is to which this pattern applies. That way, you make the utility as widely applicable as possible. -
Utilities (functions): any sequence of usage logic can be captured as a function, typically grouped around the main type they have in common. Utilities cannot have internal state and should be contextless. Any necessary values are passed in as parameters, such that there is no context to force the functions into a restricted use case.
You can define a utility object, that you construct with common values, i.e. context but still stateless, so they do not need constant repeating. -
“Generalize for three or more cases”: I have not come across this statement as a strict rule, but rather as a mnemonic. The idea for this is likely: you can generalize on many different dimensions. Basically, any type or variation you can think of. The number three, i.e. three times, is the minimum number of occurrences you need for confirmation as a pattern. The rationale lets your choices be guided by sufficient data points, such that you can make a selection based on evidence out of many potential combinations of generalizations. Therefore, prioritizing concrete hints over educated guesses, and prevent excessive or over-enthusiastic generalization.
note see remarks for explanation of a pattern in geometry. -
”You Ain’t Gonna Need It (YAGNI)": Because of the minimal-objects approach to the implementation of objects, you are spending the least amount of time to designing and/or anticipating for use cases that might. Even more, because we intend to avoid implementing for a particular use case, and instead restrict to managing data/invariants only, we avoid bias towards a particular use case.
-
Dependency Inversion-principle:
“Depend on abstractions, not on concretions”
Most aspects of this principle have already been discussed. We previously stated a rule that one needs to consider the API of a function or object. Request the type with the most precise interface, preferably only the function(s) that is/are required. Similarly, consider the return-types: select the return type that expresses exactly what you are willing to guarantee. In many cases this means choosing an appropriate interface to represent your type. It is compatible with this principle.
This principle contradicts previous rules for the use of “abstractions” in internal logic. As stated before, there is no point in hiding implementation details from other logic that shares the same responsibilities, concerns and knowledge of the invariants and state.
-
If-less programming: if-less programming prefers separate classes over diverging branches using
if
-statements. The underlying idea is that if there is anif
-statement, there is a choice between two solutions. Therefore, these could have been implemented as two solutions, namely in separate classes. One then injects the class that fits with current needs.
Conditions that are based on class fields and that haveif
-statements with the same condition repeated thoughout logic, suffer from having to manage multiple concerns.
For example, let’s take Two’s complement integer encoding. This encoding encodes an integer value into bytes. However, the bytes can be ordered, among some exotic forms that we will ignore for now, in little-endian and big-endian byte-order, indicating that the smallest parts of a integer value, respectively the largest parts of a the value, are encoded first into the series of bytes. Would you write one class for both encodings, one would need to write if-conditions repeatedly such that the correct byte-order is applied. The same condition is shared throughout the class logic, because the class represents little-endian + big-endian – multiple concerns. Conversely, if you instantiate either theLittleEndian
or theBigEndian
class, you make the choice once, then all of the class’ logic is simpler for it. This change corresponds to the expansion/reduction component in simplicity: you remove (the need for) a rule from the class’ invariants. The rule that prescribes how to handle byte-ordering/endianness.
note this is different, however, from having to write an occasionalif
-statement to cope with incidental decisions in your control flow. A well-known example is error-handling. You will have to handle errors and exceptional circumstances in some way or another. There are entire classes of errors that cannot be anticipated. -
Resource Acquisition Is Initialization (RAII): one property is particularly well-known in the C++ community: RAII. Certain dependencies cannot be constructed within the class itself and instead have to be passed to the class at construction (initialization-time). Passing the instance on during initialization-time would, in most cases, be (semantically) desirable to passing on ownership, i.e. full responsibility.
From the perspective of memory management, the rule is: if acquired at initialization-time, the acquiring type is responsible for clean-up as well. Apart from memory management, this mechanism holds equally well for handling and managing resources such as file handles, network sockets, etc. An important difference to passing on a primitive value is that instances may need to be cleaned, memory-managed, closed or otherwise handled for proper disposal.
If, on the other hand, in an arbitrary method call you pass the resource as one of the parameters, then you pass it on only for one-off use.
RAII is lesser known concept in, among others, the Java ecosystem. At least not under this name. Instead they have a similar concept: Context & Dependency Injection a.k.a. CDI. More details follow below, in its own entry. -
Dependency Injection-pattern is the pattern that demonstrates how you can do injection of dependencies and resources. Where RAII (above) describes the semantics to adopt when perfoming dependency injection, DI mostly demonstrates how to model the type structure to accomplish this. Even the Wikipedia-page on Dependency Injection does not go into the management aspect of resources being injected. If there is little to “memory management” in garbage-collected languages, even then you sometimes need to close opened files and network connections, update registry entries, drain queues, finish and close communication channels, etc.
-
Context & dependency injection is a more elaborate mechanism used by application servers to inject, apart from the typically dependencies, context over which the application itself even has no direct control. An application server typically hosts a number of applications, and manages external and internal resources for those applications in such a way that there is appropriate separation, maintainability and control. Applications need access to such resources and ask the application server to have them injected. The server then injects (controlled) resources. An application receives the context – in which it has to operate – injected at run-time. Note that this satisfies guidelines on minimizing dependence on outside types by injecting dependencies according to predefined interfaces.
CDI respects the idea of dependency injection but adds a (complicated) mechanism needed to realize this new goal. A remark below goes into more detail on the mechanism itself and its variants. -
abstract
orfinal
(exclusively): either for overriding or for use
This ensures that use cases cannot be ambiguous or conflicting. The use case of overriding based on the class and using the finalized class, are distinct intentions with different concerns. To try and supply both at the same time leaves you aiming for two dinstinct goals. In a good case it does not pose problems immediately, because concerns are similar enough. Then problems come when first change is made that did not anticipate one of the two ways the class is used (either messing up overriding or calling). By deciding beforehand whether a class is intended for overriding or use, you can define it asabstract
(overriding only) for abstract base class, orfinal
(blocks overriding, expecting usage access only) for concrete implementation. -
Domain-types/-packages: There is the notion of domain-types. Classes defined to represent many of the concepts existing in the functional- or business-domain. These types form the shared (composite) building blocks for business applications such that not every application needs to reinvent every type they use. Domain-types are, in a way, the language to speak in the functional domain, as well as in the engineering domain. As domain-types typically have few dependencies, they can be used freely without the risk of dragging in a large entangled mess of code. These types are therefore suitable to be used in abstractions, unlike other more elaborate composite types.
Domain-types are also the types that benefit most from applying the minimal-objects rules, as these should be independent types and basic representations of domain concepts. -
Test-Driven Development focuses on use cases/inputs/edge cases and ensuring that test-cases are available. TDD concerns itself less with structure, although it seems that objects are assumed. In my view (subjective), if you read through the strict steps, into the spirit of TDD, it is about methodically ensuring requirements are backed by test-cases and test-cases backed by (working) logic. This is equally well applicable to other (slightly different) approaches to structuring code, such as what is proposed here.
-
Hyrum’s Law (and related laws mentioned there) warn that even if you define an interface, there will eventually be a user for every undeclared hidden deterministic behavior – every implementation detail – to rely upon, even though this behavior is not guaranteed. If you will change your implementation such that it eliminate this behavior, some user somewhere will be affected. Consequently, implementation details are never truly safe. This is an unavoidable circumstance caused by assumptions on the part of the user. Regardless, by carefully considering types you can at least express your intent.
-
software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification
The open-closed principle encourages extension using existing classes as-is, instead of modifying existing classes. This aligns with the approach, as the rules are already stated to weed out unnecessary logic.
We previously stated that composition is preferred over inheritance. Assuming that composition is not an option, this princple basically says that it is better to derive a subclass than it is to break open an existing implementation. This aligns with the minimalism-rules. This is in conflict with the guideline for “abstract
orfinal
”, which prevents derivation, but for the very reason that is stated there.
The open-closed principle is right because it should be unnecessary to modify an existing, established concept. The same is supported by the minimal-objects rules. However, this rule becomes more relevant as a code-base evolves and bad decisions were made in the process. -
Interface segregation basically advises the minimal number of methods rule for interfaces, and in doing so makes it easier to apply the rule for considering the API.
-
Law of Demeter / principle of least knowledge is a somewhat interesting law to validate against:
- the minimal-rules ensure that only fields that are strictly necessary, exist.
- sufficiently restricted access rule ensures that the user can only access something that it cannot misuse.
- expose API as necessary rule points out that anything accessible for use is part of the API, so this includes fields. Consequently, if the field is a composite structure it allows for deeper traversal. The Law of Demeter discourages this. We can expose composites via multiple ways: both as field, and as method-result. The minimal-objects rules state that you expose only if it is relevant to the represented concept. The Law of Demeter seems to be represented – mostly if not completely – in the combination of rules, but very indrectly.
-
“Just a function”
“Sometimes, the elegant implementation is just a function. Not a method. Not a class. Not a framework. Just a function.” – John Carmack
The rules require minimal logic in methods, and the remarks point out how common sequences of usage-logic can be captured in utilities, design patterns, etc. This leaves plenty of room for logic to be captured in utility (functions). Exactly as the quote emphasizes: a most basic mechanism is sometimes just right.
Remarks
These remarks exist to give some nuance to the many guidelines that are listed. Mostly they exist to avoid going on a tangent. Instead we go into more details in a remark.
-
In geometry: it is trivial to draw any one straight line that passes through a single point. For any pair of points, there is always one straight line you can draw that passes through both points. However, for a straight line to pass through three (or more) points, is only possible if these points are properly aligned: a pattern, as opposed to points at arbitrary locations.
-
The amount of logic (number of methods, number of lines of code) is bounded for minimal-objects implementations. There are only so many lines of code you can write to enable all valid cases of modifying data. However, if you go into any number of potential (opinionated) use cases for a type, then the amount of logic you can write, and reasons you can find to extend, are boundless.
-
(In general) Solutions that go against the nature of the programming language need careful considerations. Early CDI solutions would use reflection to inject dependencies after construction (CDI field-injection) leaving class definitions with limitations due to this mechanism. Later mechanisms for CDI mitigate this by offering multiple variants, giving you choices that do not circumvent syntax rules. Compiled languages have benefits exactly because of compile-time guarantees.
-
Most ways in which cohesion and coupling are explained is in measuring “degrees”. Degrees of belonging together (cohesion) or of correlation with other types/packages/etc (coupling). All of these give you numbers without context. A complicated situation may warrant a higher degree, but you will not be able to determine that by reading the number alone.
The approach in which cohesion is expressed, in the guideline above, is different in that it finds a boundary. A strict condition that works regardless of how complicated the situation might be. Is the represented concept small and requires only1
field to represent, then the boundary is at1
. Is the concept extensive, and cannot be minimized to fewer than25
fields without breaking the representation, then25
is the boundary. This fixed-point is found, regardless of circumstances or functional domain, and without arbitrary numbers for limits.
note the fixed-point approach cannot account for semantically different ways of representing a concept. -
Possible explosion of class internals unless minimal: there is a certain minimum number of lines of code that is necessary to implement the class. Any less, and you cannot properly use the represented concept (i.e. make all the changes and be able to guarantee the class’ invariants). However, any logic added in excess of what is needed to manage the internal state is opinionated: it assumes a certain way of working and the logic preselects for that use case. One can imagine any number of ways in which to use the class. If we want to capture all of this logic in the class itself and satisfy all these use cases, the amount of logic will explode. The minimal approach, however, has a clear boundary: “the necessary logic to make the change and preserve/restore the invariants”.
Now that a class itself is defined, repeated use will expose the common patterns. These patterns can be captured in utilities such that repeated use becomes more concise and expressive. -
Applying single-responsibility in this way, ensures that you do not get the “I wanted the banana, but I got the Gorilla, and the whole jungle!"-problem. It also emphasizes the scope of a class. Alternative meanings for “single responsibility” includes a class having a single concern to manage, but this would make it equal to the “single concern” entry, so this meaning is already covered.
-
There have been discussions on whether or not the use of (utility) functions is against the OOP paradigm / OOP best-practices. I do not believe this is the case. The rationale regarding this, will be discussed in a later post. Regardless, a (utility) function is the most convenient, accessible way of sharing common usage logic. Utilities are a mechanism at the implementation-stage to reuse existing work, while OOP impacts the design-stage. These are not mutually exclusive, because they operate on different levels of abstraction, with different concerns and goals.
-
Notice that there is no guideline for a maximum method length, in e.g. number of lines of code. This is intentional, partly because of the goal to not use arbitrary numbers. However, you might have noticed that there are always circumstances where the maximum size is one short. This is frustrating and will always be frustrating. Instead, the mechanism to minimal methods is itself an inhibiting factor. There is more value in a method that is 60 lines long and satisfies the minimal-objects rules, e.g. a method containing a lengthy
switch
-statement.
The minimal-objects approach, if followed, cannot lead to arbitrary length methods because you would: 1.) be violating some minimalism-rule, or 2.) be failing to extract appropriate utilities. -
Coupling has always been a key indicator of a code-base becoming complicated. I wonder if coupling is actually the desired way to measure this. Cohesion can be explained as a very specific measurement that, following minimal-objects, has almost a boolean output. Coupling, on the other hand, seems like an aggregate for wrong selection of types, dependency inversion, inappropriate use of classes while interfaces are available and more appropriate, and so forth. In addition, the instances that identify suspicious data access patterns, such as global data and other non-local data in unexpected locations, should maybe be detected through dedicated analyses. Regardless, you can measure but what does the outcome actually contribute? (subjective)
-
It seems that fields have status such that they are expected to be internal-only for most, if not all, cases. For example, in the Law of Demeter but also through the best-practice of creating “getters” and “setters”. Minimal-objects, indirectly through lack of further rules, does not enforce this.
Common problems
A number of common problems in programming. Problems that most likely are the result of failing to manage simplicity.
This section is by no means exhaustive, or intended to be. It lists problems that came to mind as I was writing the document.
Banana-monkey-jungle problem (entanglement)
a.k.a. spaghetti-code
A common complaint about OOP is that it encourages the user to create an entangled web of objects using types such that they all depend on each-other. The analog for this is a developer wanting the use banana
but in using the class, you are required to pull in the “monkey holding the banana”, and also “the entire jungle”. With which they mean to say that: the banana depends directly or indirectly on the monkey, and the monkey directly or indirectly on the jungle, and the jungle contains many more of these artifacts, and all of them are inseparable. So if you want to take one small element, you are obligated to accept everything by (transitive) dependency.
This is a fairly common problem, it is real. However it is also based in misunderstanding of how OOP is used. It is important to understand that OOP offers a way of creating abstractions. The logic inside a class must not know about or anticipate on the logic outside the class. The class API is the boundary between these two worlds. In the API, you should carefully consider which types you want to depend on: typically basic types of the language and standard library, and chosen domain-types. With domain-types having few other dependencies and representing most common concepts for the functional-/business-domain. If, on the other hand, the type has specific requirements, it can define an interface to be implemented by users. The interface does not carry the web of dependencies, and using the interface does not require the class to know intimate details about other concerns. It enables separation. Other types and design patterns can be applied to further close the gap between the type and convenience in it.
Taking a well-defined banana
-type, gives you a banana
-type applicable within its domain, and nothing more. The domain is relevant here, because simply saying banana does not tell you whether it is meant as food, a swimming pool float, a banana-shaped storage case, or whatever else. And you will have to figure out for yourself how you are going to use it, and whether it is useful, and suitable for your problem.
Technical debt (implementation)
Technical debt is an often-mentioned problem. It represents – as a rough definition – the code that needs to be maintained but serves no useful purpose. Therefore, this code carries a cost, and the longer the code exists, the more is spent. As this article focuses on implementation matters only, we will equally restrict our view of technical debt.
note Technical debt as explained at Wikipedia covers a very wide range of topics, including multiple causes for the same consequence, e.g. “incomplete feature”. Here we will assume completed functionality for the sake of scoping.
There are different forms of technical debt, not all equally expensive. Trivial fixes include tweaks to logic and naming of variables such that things are more understandable. Technical debt in implementation-stage (logic) is trivial to fix. Significant changes at implementation-stage are indicative of bugs, failure to satisfy requirements, or absent behavior due to lack of requirements. This is not technical debt.
According to claims in this article, we should consider utility (function) extraction implementation-stage technical debt. It makes sense as utilities are context-free and stateless, so there are no real design choices to be made. However, the extraction of utilities or substitution of utilities for common sequencies of logic is no complicated effort, and on top of that is supported by development tools.
The expensive kinds of technical debt are all rooted in design: various flavors of bad decisions, all resulting in more complicated code than necessary. This includes unnecessary use of design patterns, incorrect use of design patterns, (anticipated) features without actual requirement, requirements implemented using more than necessary design (components) – often going unrecognized, etc.
CDI - Field-injection
There is a longstanding matter of CDI, in particular how CDI is (often) implemented. Many solutions for CDI would circumvent the strictness of the language at compile-time (syntax) by injecting resources after construction. A consequence of this is that many syntactical programming language features cannot work, are negated or are no longer possible to use.
CDI is known in at least three flavors: constructor-injection, method-injection and field-injection. Field-injection injects dependencies only after the object is constructed with null or some other instance. Protective measures such as immutability (in Java: final
fields) cannot work. Access modifiers that protect fields from outside influence through privileged access (in Java: private
) are circumvented at run-time using reflection.
Method-injection does not circumvent syntactic restrictions. However, given that methods can only be called after construction, this form only works for optional dependencies, meaning you never have the guarantee that the field is actually present. Constructor-injection is the only solution that works with the syntax and can achieve injection of dependencies and context at the right time and be able to satisfy requirements of immutability and privileged access without circumvention. However, constructor-injection is not available in a number of Dependency Injection-frameworks. Consequently, you are only offered bad choices.
Later frameworks that offer CDI, such as Dagger, recognize this and work primarily with the syntax. They offer the benefit of automatic resolution without circumventing language rules. It offers the “automation-magic” while respecting the programming language (syntax).
For example: the previously mentioned CDI field-injection has only one reasonable use case – AFAICT: if you have corrupt data in a data-store, and your models aim to perfectly represent the (possibly bad) data from that store, they can carry bad data, violating class invariants in the process. This use case is almost never desirable. Obviously, you end up with corrupted class instances. The closest you would get, is acquiring the data and then fixing it or clearing it before use. For any reasonable use case, constructor-injection (for required fields) and method-injection (for optional fields) are solutions that work without circumventing syntax.
(Excessive/unnecessary) injection vs utilities
CDI is about context injection. About providing instances that are prepared with knowledge foreign to the receiving class. In case of injecting trivial objects, “services” or “utility objects” or however you want to call them, that take no initialization or whose initialization can be done internally, provide no benefit if injected.
“Utility objects” or stateless “services” are not dependencies or “unknown context that needs to be provided by the user”. These are pieces of logic that you know you need to execute, because it is part of internal logic. Note that this touches on how utilities are merely an implementation convenience, not a design consideration.
Both static utility functions and utilities (objects) that can be instantiated internally are viable candidates. This saves on parameters in the constructor, and can be constructed whenever needed. Injection, on the other hand, makes its construction more difficult, and therefore the accessibility of its logic. Furthermore, it is important to consider what happens if construction parameters change. Can the class, that receives this instance at injected, cope with the changed initialization? This is particularly tricky if most methods that are needed could also be accessed as static functions.
In an previous post on objects as utilities, the differences between a basic utility function and an object that provides utility logic is discussed in more depth. This may help to emphasize the added benefit of it being an instance, therefore a reason for injecting. Otherwise, if all you need are utilities, there is no benefit to injecting.
Perceived problems
There are some “perceived problems”. Matters that seem more difficult than they actually are.
“Everything is an object”
Due to conflicting notions of OOP, there is this myth that everything must be an object. Consequently, even the most trivial utilities “must be provided as an object”. If you feed this perception, you then need to inject it appropriately either through CDI or dependency injection pattern. Suddenly you end up suffering the cascading costs of the abstraction, but no benefits.
CDI “solves” entangled mess of dependencies (tight coupling)
Using CDI with the intention to hide where your dependencies are coming from. Of course there are relations between the parent class that creates an instance and the instance itself. If the instance must be constructed with many dependencies (being injected) due to it being complicated/comprehensive, then this is reality.
Using CDI to hide that these dependencies/types are needed, and consequently lowering the degree of coupling, solves no actual problem. On top of that, it hides important signals concerning the structure (and health) of the class. If the issue is “too much entanglement” there is no point in merely trying to hide the entanglement.
Objects perform message passing, therefore unpredictable (non-deterministic)
It is said that methods are inherently unpredictable because methods are not “called” but “message passing” is performed instead. This is meant to be literal, i.e. not a symbolic imagined/envisioned way of treating objects, and a misconception that stems from the fact that there exist multiple definitions of OOP. A future article will go into significantly more depth on this topic. For now it suffices to say, that methods defined for an object as-in encapsulation, as with programming languages like Java, are function calls.
In the linear flow of logic, a call to a method is executed “at that place/time”. Therefore it is easy to predict when the method is called: look at its place in the larger whole of the logic. There can still be non-determinism, i.e. a factor of unpredictability, but only if there are other complicating factors, such as multiple threads, randomness, waiting/blocking behavior, etc.
Minimal-objects: free of tech-debt?
In an earlier section, we discussed the matter of technical debt in code-bases. We noted that any significant technical debt is rooted in design choices. The technical debt that emerges at the implementation-stage, are sufficiently trivial that they can be fixed without much effort.
The scope of minimal-objects is that of a single class: its implementation, using prescribed rules for design decisions. The minimalism helps to prevent most (if not all) of the misplaced logic. In a way, this is about preventing design mistakes, but specifically for decisions on the contents and implementation of a single class.
We could claim that, using this approach, we can implement classes without (the risk of) technical-debt. However, in order to do that we need to agree on two rules:
-
the class represents a concept, the minimal-objects rules allow for methods that are valid (controlled) changes to the represented concept – even if they are currently not strictly needed.
For example – in Java, but language is irrelevant –BigInteger
class allows for multiplication even though you only need addition. This is not considered technical debt, because it is a valid operation for this concept. -
it is agreed that the minor changes and improvements that may exist from the implementation-stage are negligible.
Assuming both are acceptable, it means that any significant technical debt is rooted in design decisions and would, consequently, be pushed out of this class. This means that, even if changes are made to the class at a later moment, the rules of minimal-objects would still guard against introducing technical debt.
The “long game” is about pushing design decisions (and technical debt) into their proper place, closer to the business logic / core of the application. This results in lean, reliable base packages, such as domain-types, that represent your functional-/business-domain concepts and are reusable across applications.
It is an illusion to think that you can easily eradicate tech-debt once and for all. To do that takes careful consideration of all design aspects at all times. Instead, we gain benefits from having technical debt concentrated in the parts that already require the most change, while the parts that could stabilize get to do so.
Design Patterns
Given that technical debt is primarily concentrated in design-aspects, it is interesting to entertain the following thought: “design patterns are free of technical debt.” To clarify, you can certainly make the decision to apply design patterns unnecessarily. Each of these will contribute to technical debt, because nothing requires the pattern to be used in the first place. Design patterns themselves, however, contain only the very minimal to achieve the desired goal. Therefore, proper use – the pattern is applied appropriately – is free of technical debt.
In the previous section, we discussed how minimal-objects is free of technical debt. The reasoning: whatever is allowed to exist according to minimal-objects is fundamental to the concept, so even if not used, it still represents the concept. Minimal-objects disallows any methods that do more than strictly necessary. This prevents bloating the class with opinionated logic. This logic goes somewhere, of course. It is pushed outward towards the using classes, or potentially in utilities in case of common (context-free) logic.
Design patterns are presented as the very minimum necessary to fulfill a goal. It leaves some parts open for filling in by the user. For any design pattern, the focus is on providing a structure to solve the one specific problem for which it was designed: no unnecessary methods, no unncessary fields (if any at all), minimal API (to make the pattern work).
Existing projects
Minimal-objects provides a way to handle individual classes. So, it can work for existing code-bases too. There is the consideration that minimal-objects does on occasion change APIs, so for API-breaking changes you have to account for interference with its users or a wrapping class (design pattern) to negate the changes.
- it works best starting at classes with few(est) dependencies, such as domain-types, often at the leafs of the call-hierarchy.
- it requires understanding the concern, so if your class has grown out of control and you cannot understand it, it is more difficult to separate its violating parts:
- separating the methods/logic requires full comprehension of the data management involved,
- comprehending the data management requires understanding the relationship between fields. Ideally, both having an overview and comprehending the concerning invariants.
- given either full comprehension, or with partial comprehension and discovering further dependence on-the-fly, we analyze the field relationship: identifying options for separating based on none/uni-directional/bi-directional relationship as described previously.
- the rules influence acceptance criteria for class membership, therefore indirectly influences the class’ surroundings through the eviction of access, fields or methods.
Applying minimal-objects afther the fact, would give a transformation of a large class into smaller class(es), possibly use for design patterns such as decorator, possibly use of existing or introduction of new utilities, and probably contributions to using (business) logic.
some class ↝ minimal class(es) + utilities + design patterns + contribution to business logic
In a dedicated article, it would be reasonable to go into more details regarding this approach. However, it is essentially the same regardless of the state of the code-base, or the size, or the language. Thoroughly understanding this mechanism will allow you to apply it regardless of the circumstances, simply because the mechanism takes a single class as target.
Fast and efficient development
So how does all of this infuence development?
Arguably, this approach is straight-forward and intuitive. More so than forcing everything into an object. Objects, like other mechanisms, exist to solve a particular kind of problem. Forcing it means adopting a certain solution before the problem exists. There are already clear circumstances when the object (as an abstraction) is suitable and also the appropriate solution.
The rules explain how to design a minimal class: the rules themselves emphasize deciding factors, while combinations have yet other (beneficial) consequences. The guidelines discuss how the mechanism integrates with common understanding and existing best-practices.
There are a number of benefits:
- Minimal-objects aims for minimal objects, but not at the cost of many objects. Instead, it relies on utilities for readily available logic to make statements (much) more expressive. This results in efficient logic in objects and easy reuse of common sequences.
- Minimal-objects relies on encapsulation, but works with either type-hierarchies or the various structured typing variations. Java, C#, Go, Rust are all equally suitable. The mechanism does not override the nature of the programming language itself. Instead, it only depends on fundamental principles of OOP.
- Domain-types carry fewer dependencies and can therefore be used to raise the expressiveness of code in an application. A well-constructed domain-package can be used even for parameters and return-types in abstractions, without introducing “leaks” or entangling abstractions with application-/business-logic.
- Utilities are defined contextless, therefore reusable even over different code-bases.
- As noted earlier, the minimalistic approach prevents class implementations from becoming opinionated:
- this ensures that implementations can be used wherever the concept applies.
- this ensures that design patterns can be applied easily, because there is no need to mitigate for inconvenient implementation choices.
- consequently, it creates a basis for reuse (with utilities, design patterns, etc.)
- Single concern: minimize number of fields until only strictly necessary fields remain. (This rule essentially coaxes data into its proper place.)
- No confusion about the responsibility of a class: fields/state, and more formally the underlying, often undocumented invariants.
- No entanglement. (Banana-monkey-jungle problem)
- Minimal-objects approach pushes out state/data that does not belong. Consequently pushing context and business requirements upward in the call hierarchy towards other business logic. This ensures that requirements, context, and resources are provided (injected) from business logic, rather than hidden deep inside opinionated implementations.
- Avoids:
- boilerplate,
- excessive structure / forced use of classes,
- excessive concern with context: classes typically require initialization, while contextless, stateless, (pure) functions do not.
Programming languages are often judged by how many statements/lines you need to accomplish tasks. In OOP, utilities are often shunned because these are not “true objects”. In this article, I claim that this is the wrong way of looking at utilities. If classes are defined according to minimal-objects, i.e. guided primarily by simplicity, it becomes easier to find and utilize common sequences of usage. Given a mature collection of utilities for each type, any type can be used efficiently. This, in no way discourages the use of objects, rather it encourages appropriate use.
Conclusions
Minimal-objects aims to apply Object-Oriented Programming (OOP) in implementation while respecting simplicity, meaning its dimensions are goals in itself: reduced, specialized and unoptimized. This is achieved following the rules outlined in the beginning of this article. In the previous section we discussed various benefits of this approach, essentially resulting in easier, more straight-forward development practices.
The minimal-objects approach ensures many beneficial properties are viable. It is a way to achieve simplicity, or said differently: minimize complexity. It results in smaller types and leaves room for reuse of usage-level logic. It does not aim for everything being an object. The approach describes boundaries in such a way that it does not rely on arbitrary numbers. This makes it easier and more natural to apply, as well as less frustrating when developing.
We have discussed properties, rules, best-practices of Object-Oriented Programming. The explanation is guided by the notion of simplicity. The attempt to create an explanation that works for all cases is an exercise to determine the suitability of both the “minimal-objects” idea and the definition of simplicity itself.
Many conflicting or unexplained properties would have been a clear sign of an infeasible idea. As it turns out, this is not the case. This approach has little to no conflict, indicating that the notions are feasible and reasonable. The article explains – as well as outlines – the rules and best-practices, therefore hoping to pass on the rationale as well.
This can be interpreted as “just a set of rules to produce “good” code”. The rules should help you with this, given that the idea seems feasible. At the same time, realize that this goal is unfulfilled. The actual goal is not about coding, either good or bad. For example, it does not discuss other concerns of coding, such as error handling. Rather, it is about establishing and validating an understanding of OOP in general. This is part of a larger effort.
Open questions
- Evaluate and discuss simplicity/complexity for the OOP paradigm(s)
- Elaborate more on an approach and practical matters for existing code-bases.
- Complexity can be analyzed manually. Can we use/adapt static analysis tools, such as pmd or error-prone, to validate code against minimal-objects rules?
- Can we validate (correct) application of minimal-objects? For example:
- identify common sequences of privileged logic within same class.
- identify common or long sequences of usage logic within same class.
- identify common sequences of usage of the class.
- identify methods that do not (strictly) require privileged access.
- An outline of different variations of cohesion and coupling w.r.t. minimal-objects:
There are quite some variants of both cohesion and coupling. It might be good to give them a proper place w.r.t. the minimal-objects way of working. This may potentially uncover edge cases not handled to satisfaction by minimal-objects. Furthermore, analyze the usefulness of the coupling measurement as opposed to analyzing the individual characteristics with varying severity.- University of Washington: course-page on cohesion and coupling offers an interesting overview of different levels (not strictly linear) of cohesion and coupling.
- Wikipedia offers similar lists on levels of cohesion and levels of coupling.
disclaimer this article will, invariably, stretch some properties or guidelines, either because there is no consensus in the industry, or due to the effort of mapping these guidelines onto minimal-objects. Things will likely be left out or simplified.
The article intends to illustrate the general idea. I have tried to avoid misrepresentation, even in light of methodologies other than being described here.
References
These references are also present in-place throughout the post. The final post in the series will include many more references that were used over the past years. Wikipedia-articles are used as a quick-reference for confirmation, rather than an authoritative (single) source.
Following are references used in this article. There are also shared references.
- Big-O notation (blog post)
- Blog post: Concluding Simplicity
- Blog post: OOP - implementation vs usage
- Blog series on Object-Oriented Programming
- Enterprise Fizz-Buzz: example code with many design patterns
- Hyrum’s Law
- Software: pmd - static analysis
- Software: Dagger - dependency injection
- Software: ErrorProne - static analysis
- University of Washington: course-material: Cohesion and Coupling
- Wikipedia: Cohesion
- Wikipedia: Coupling
- Wikipedia: Composition over inheritance
- Wikipedia: Design Pattern
- Wikipedia: Dependency Injection
- Wikipedia: Dependency Inversion principle
- Wikipedia: Endianness (little-endian, big-endian)
- Wikipedia: Interface Segregation principle
- Wikipedia: KISS principle
- Wikipedia: Law of Demeter / Principle of least knowledge
- Wikipedia: Loose coupling
- Wikipedia: Open-closed principle
- Wikipedia: Resource acquisition is initialization
- Wikipedia: Single Responsibility-principle
- Wikipedia: Technical debt
- Wikipedia: Test-Driven Development
- Wikipedia: You Aren’t Gonna Need It
- Wikipedia: Necessity and sufficiency
Changelog
This article will receive updates, if necessary.
- 2022-04-05 Added link to shared references (post).
- 2022-03-25 Minimal changes in wording in References section.
- 2022-03-23 Added TL;DR-like remark.
- 2022-03-01 Initial version.