A New Type: To Be or Not to Be…

Sasha Terentev
5 min readFeb 6, 2024

--

When writing code, one of the fundamental questions to address is whether it’s worth introducing a new data type (such as a Class, Struct, Enum, etc.) or not. In this article, I aim to share the guidelines I follow to when deciding whether to incorporate a new type or abstraction into my codebase. Additionally, I will explore key aspects that can assist in identifying redundant abstractions within the code. I apologize in advance if some of the content in this article appears self-evident to some readers

Key aspect

Unique responsibility

When determining the necessity of a new abstraction, the foremost consideration is whether it serves a distinct and unique purpose or function.

For many of us, the concept of a unique responsibility might be synonymous with a unique function. However, when discussing the Single Responsibility Principle (SRP), it’s important to note that, if I have understood the principle correctly, this interpretation is not entirely accurate. Robert Martin elaborated on the essence of the SRP as follows:

“Gather together the things that change for the same reasons. Separate those things that change for different reasons.”

Or, as he conveyed in his book “Clean Architecture”:

“A module should be responsible to one, and only one, actor.”

Due to the potential for various interpretations of the concept of responsibility, I find this aspect to be somewhat abstract and open to debate.

While I acknowledge the merits of the Single Responsibility Principle as defined in Clean Architecture, I would like to shift the spotlight onto a different aspect of the code.

Lifecycle

When it comes to designing new features or entire systems, I often find the lifecycles of their components to be more evident and well-defined compared to abstract responsibilities. In many cases, these component lifecycles naturally emerge from the associated data flows or even from product and design requirements.

A new type. To be

I recommend creating a new abstraction when you recognize that a particular system component or data flow possesses its own distinct lifecycle that has not yet been represented within the system.

In my previous article I discussed a scenario where creating an abstraction for the entire onboarding process, using the Saga/Coordinator pattern, helps the code align with the product requirements, making it “as it heard from the design”.

At the end of the aforementioned article, you can discover additional instances of this approach.

Not to be

Occasionally, we may come across a situation where an existing data type either becomes redundant over time or was initially created with redundancy.

I’m referring to scenarios where two or more types share entirely interconnected lifecycles.

Trivial example

Several times, I’ve encountered API requests designed in the following manner:

var identifiers: ["id1", "id2", ... "idN"]
var payloads: ["Payload1", "Payload2", ... "PayloadN"]
makeApiRequest(identifiers: identifiers, payloads: payloads)

In this example, despite the fact that both arrays, identifiers and payloads, contain precisely the same interconnected data, the information is separated.

While such solutions may have valid reasons for their existence, they invariably lead to the following issue.

Inconsistency

Since the same data is partitioned into separate entities, it becomes impossible to verify consistency during compile time. Consequently, additional consistency checks and assertions are introduced into the code. Therefore, in general, code consistency relies on:

  1. The best case: Documentation (if available).
  2. The worst case: Verbal agreements or the author’s mind.

In the case of this straightforward example, the receiving side will most likely have code similar to the following:

func handleRequest(identifiers: Array<ID>, payloads: Array<Payload>) {
assert(identifiers.size == payloads.size)
// handle
}

In real code, potential inconsistencies may be far less apparent and manageable.

If such a code design is present in your system, you may not only need to introduce consistency checks but also write synchronization code for the “scattered” entities.

Example from UIKit

I’m concerned about the separation of UICollectionViewLayout and UICollectionViewDataSource abstractions. While I’m certain the authors had valid reasons for dividing the content and associated layout information of the collection items, from my perspective:

They essentially represent the same data collection, which is the content (items) of aUICollectionView.

They have almost identical reasons (triggers/moments) to change: updates of the collection state (UI, layout).

Both classes consistently act together, raising the question of whether they fulfill the same actor in terms of the Single Responsibility Principle?

Only the layout state (UICollectionViewLayout) is the final source of truth for the collection, as the collection relies solely on layout information to display its current items.

Undoubtedly, UICollectionView is not as abstract as declarative frameworks, so it’s a rude comparison, however, but it’s worth noting that in SwiftUI, a collection is inherently designed to take only the items array.

Since the separation of UICollectionViewLayout and UICollectionViewDataSource shares a common trait with our earlier trivial example, it exhibits the same drawbacks. These drawbacks are more likely to arise when we cannot utilize the flow or compositional layout and must implement a custom layout.

Inconsistency

Let’s welcome the NSInternalInconsistencyException which has a tendency to randomly occur during collection updates.

Inconsistencies between the layout and the data source may not always be immediately recognizable and can be challenging to reproduce.

The precise cause of an encountered NSInternalInconsistencyException can vary. For instance, it may be caused by the asynchronous nature of UIKit updates (more precisely, CATransaction lifecycle, I described it here) coming together with incorrect data source updates (when reloadData and performBatchUpdates are mixed, or data source changes happen outside the updates block). Nevertheless, even in such cases, I firmly believe that if the data source and layout had not been separated (meaning, if they had been contained in a single array of collection items), we wouldn’t have experienced NSInternalInconsistencyException during UIKit updates, as the design would have prevented inconsistent layout and data..

As a result, as Inconsistency is possible, we might find ourselves needing to write additional code to synchronize the layout and data source.

Conclusion

In conclusion, here is my checklist for determining whether a type should be removed or added:

  1. Eliminate redundant types if they represent the same logical data;
  2. Introduce a new type, if a new unique lifecycle has been identified.

It’s important to note that this is not a strict rule but rather my recommendation.

--

--