Engineering: The “minimal-objects” approach to OOP

❝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.

  1. 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.

  2. Every method is necessary (minimal number of methods)
    Every method requires privileged access, accessing a member.

  3. 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.

  4. 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.

  5. 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.

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.

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.

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.

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:

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.

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:

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

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.

Changelog

This article will receive updates, if necessary.


This post is part of the Simplicity in engineering series.
Other posts in this series: