Your Methods Should be “Single Level of Abstraction” Long

Most software developers recognize that methods in their code shouldn’t span hundreds of lines of code (let alone thousands). That’s one of the few coding principles that we, developers, have a broad consensus about. However, when it comes to actually specifying how long methods should be, this fragile consensus breaks apart: some say that methods should fit onto a single screen; others cap the number of lines with a specific number; congnition-oriented developers suggest that methods should be short enough to fit into your head; and there are more approaches.

I rarely ever think about the length of my methods. Instead, I obey by another, more important principle, which has this nice side effect of naturally producing shorter methods. This principle is called Single Level of Abstraction Principle (SLAP).

As its name suggests, Single Level of Abstraction Principle says that every method inside your codebase should deal with concepts related to just one level of abstraction. No mixing. Sounds simple, but, in practice, it can be challenging to apply this principle in a real code because it is… well, abstract. Therefore, in this post, I’ll explain what Single Level of Abstraction Principle means and show several examples of its application in code.

Abstraction

Abstraction is something that hides irrelevant information. This definition might sound totally unpractical, so let me demonstrate what abstractions do with a simple analogy.

When you interact with your computer, you use a screen, a keyboard, a mouse and other peripherals. You don’t care about plastics, glass, PCBs, capacitors, chips, fans, etc. And you surely don’t care about transistors. All of these lower-level details, as important as they are for a functioning computer, are irrelevant when you work or play games. In this example, the external IO devices of your computer, as well as its external physical body, are abstractions. They hide most of the lower-level details from you and expose the minimum amount of information and features that let you use your computer.

Think about this: if we’d need to comprehend even 1% of the complexity of our computers before we could use them, most people wouldn’t be able to write even a single line of text. Therefore, abstraction aren’t optional or second-class constructs. They are mandatory tools. In fact, for most users, the higher-level abstractions (e.g. website) are much more real than any lower-level details (e.g. data transfer).

Source code also leverages the power of abstractions. A class, for example, hides most of its complexity from the outside world and exposes a simple set of features (hopefully) in the form of its public API. So, a class is an abstraction. But methods are abstractions too, as they hide most of their complexity as well. And source code by itself is an abstraction, because it allows you to use statements like if or for without dealing with instructions, program counters, registers, etc.

All in all, we see abstractions “over” other abstractions everywhere. This progression of “abstraction levels” goes on and on and on. Even transistors, which we often see as the most fundamental components in computer engineering, are abstractions over lower-level EE concepts and solid-state physics.

Single Level of Abstraction Principle (SLAP)

Back to SLAP, which advocates that every method in your codebase should deal with just one level of abstraction.

This definition might still sound odd because, in the previous section, I said that a method is an abstraction by itself. So, isn’t it the case that a method naturally deals with just the level of abstraction associated with being a method? Well, that’s true in general. However, just like a screen which can both show information and take input, a single entity can represent different abstractions when looked from different point of views. So, a method is indeed an abstraction all by itself. This is true from a “structural” point of view, if we look at it as one of the language constructs. However, SLAP looks at a method as a container for code that manipulates other conceptual abstractions related to app’s features.

According to SLAP, source code inside each method should refer to concepts and mechanisms relevant to just one level of “operational complexity” of your application. For example, if a method builds and executes HTTP requests, it shouldn’t also process the resulting JSON. These are different abstraction levels: one is about communication over HTTP and the other is about specific data format and layout.

In the next sections, I’ll show several code examples which will clarify SLAP for you.

Refactoring to SLAP – Example 1

Consider this method from Google’s official IO Scheduler application:

    override fun toggleFilter(filter: Filter, enabled: Boolean) {
        if (filter !in _filters) {
            throw IllegalArgumentException("Unsupported filter: $filter")
        }
        val changed = if (enabled) {
            _selectedFiltersList.add(filter)
        } else {
            _selectedFiltersList.remove(filter)
        }
        if (changed) {
            _selectedFilterChipsList =
                _selectedFiltersList.mapTo(mutableListOf()) { it.asChip(true) }
            val index = _filterChipsList.indexOfFirst { it.filter == filter }
            _filterChipsList[index] = filter.asChip(enabled)

            publish(true)
        }
    }

The name is toggleFilter, so we can already get a feeling of what it does. The first if statement checks for illegal argument. The second if statement either adds or removes the filter, so it’s related to filters. However, the implementation of this statement exposes us to the fact that filters are stored in some list. Not critical, but, strictly speaking, “toggling” something and “adding” something aren’t exactly at the same level of abstraction. And, lastly, if the second if results in a change, the code then goes into the third if condition. This last block of code contains some lower-level manipulations which don’t read naturally to the untrained eye. Therefore, you can’t understand what’s the full high-level flow of “toggling” right away.

This code violates SLAP, so let’s fix it. That’s what I came with:

    override fun toggleFilter(filter: Filter, enabled: Boolean) {
        if (filter !in _filters) {
            throw IllegalArgumentException("Unsupported filter: $filter")
        }
        if (enabled) {
            addFilter(filter)
        } else {
            removeFilter(filter)
        }
    }

    private fun addFilter(filter: Filter) {
        if(_selectedFiltersList.add(filter)) {
            updateFilterChips(filter, true)
        }
    }

    private fun removeFilter(filter: Filter) {
        if (_selectedFiltersList.remove(filter)) {
            updateFilterChips(filter, false)
        }
    }

    private fun updateFilterChips(filter: Filter, isSelected: Boolean) {
        _selectedFilterChipsList =
            _selectedFiltersList.mapTo(mutableListOf()) { it.asChip(true) }
        val index = _filterChipsList.indexOfFirst { it.filter == filter }
        _filterChipsList[index] = filter.asChip(isSelected)
        publish(true)
    }

Now we have “toggle”, “add” and “remove” functionalities explicitly. In addition, while reading the original code, I realized that there is an abstraction called “filter chips” which is different from “filter”. I don’t know what this means (and I don’t care), but, according to SLAP, manipulations on this abstraction should also be extracted into a standalone method. So I did that.

I find the version that follows SLAP cleaner and much more readable. Now, when reading this code, I know when I go out of “filter” level of abstraction into “filter chips” level, so I can just stop if I’m not interested in crossing that boundary. And, of course, all these actions are ready to be reused at will if that would be required in the future.

Please note that the original version wasn’t excessively long to begin with, so I would probably consider it to be OK if I’d judge my methods by just the number of lines of code. This shows you how much more powerful SLAP is compared to counting the lines.

For the record, I don’t think that the refactored code is ideal either. It’s much better than the original, sure, but it’s still quite dirty. The biggest remaining problem there is abuse of boolean method arguments, but this problem is outside the scope of our discussion here.

Refactoring to SLAP – Example 2

The second example is yet another method from the same Google IO application:

    private fun showFeedItems(recyclerView: RecyclerView, list: List<Any>?) {
        if (adapter == null) {
            val sectionHeaderViewBinder = FeedSectionHeaderViewBinder()
            val countdownViewBinder = CountdownViewBinder()
            val momentViewBinder = MomentViewBinder(
                eventListener = model,
                userInfo = model.userInfo,
                theme = model.theme
            )
            val sessionsViewBinder = FeedSessionsViewBinder(model)
            val feedAnnouncementsHeaderViewBinder =
                AnnouncementsHeaderViewBinder(this, model)
            val announcementViewBinder = AnnouncementViewBinder(model.timeZoneId, this)
            val announcementsEmptyViewBinder = AnnouncementsEmptyViewBinder()
            val announcementsLoadingViewBinder = AnnouncementsLoadingViewBinder()
            val feedSustainabilitySectionViewBinder = FeedSustainabilitySectionViewBinder()
            val feedSocialChannelsSectionViewBinder = FeedSocialChannelsSectionViewBinder()
            @Suppress("UNCHECKED_CAST")
            val viewBinders = ImmutableMap.builder<FeedItemClass, FeedItemBinder>()
                .put(
                    sectionHeaderViewBinder.modelClass,
                    sectionHeaderViewBinder as FeedItemBinder
                )
                .put(
                    countdownViewBinder.modelClass,
                    countdownViewBinder as FeedItemBinder
                )
                .put(
                    momentViewBinder.modelClass,
                    momentViewBinder as FeedItemBinder
                )
                .put(
                    sessionsViewBinder.modelClass,
                    sessionsViewBinder as FeedItemBinder
                )
                .put(
                    feedAnnouncementsHeaderViewBinder.modelClass,
                    feedAnnouncementsHeaderViewBinder as FeedItemBinder
                )
                .put(
                    announcementViewBinder.modelClass,
                    announcementViewBinder as FeedItemBinder
                )
                .put(
                    announcementsEmptyViewBinder.modelClass,
                    announcementsEmptyViewBinder as FeedItemBinder
                )
                .put(
                    announcementsLoadingViewBinder.modelClass,
                    announcementsLoadingViewBinder as FeedItemBinder
                )
                .put(
                    feedSustainabilitySectionViewBinder.modelClass,
                    feedSustainabilitySectionViewBinder as FeedItemBinder
                )
                .put(
                    feedSocialChannelsSectionViewBinder.modelClass,
                    feedSocialChannelsSectionViewBinder as FeedItemBinder
                )
                .build()

            adapter = FeedAdapter(viewBinders)
        }
        if (recyclerView.adapter == null) {
            recyclerView.adapter = adapter
        }
        (recyclerView.adapter as FeedAdapter).submitList(list ?: emptyList())
        // After submitting the list to the adapter, the recycler view starts measuring and drawing
        // so let's wait for the layout to be drawn before reporting fully drawn.
        binding.recyclerView.doOnLayout {
            // reportFullyDrawn() prints `I/ActivityTaskManager: Fully drawn {activity} {time}`
            // to logcat. The framework ensures that the statement is printed only once for the
            // activity, so there is no need to add dedupping logic to the app.
            activity?.reportFullyDrawn()
        }
    }

Now, even if developers never heard of SLAP, this method would still violate any reasonable threshold for the maximal length. In some cases, long methods are unavoidable (e.g. long switch block with tens or even hundreds of cases), but this isn’t one of them. This is just dirty code, so let’s take care of it.

Most of this method’s code resides inside the initial if check, which concludes with writing into adapter variable. In other words, most of showFeedItems method’s body doesn’t deal with either feed or items, but with a lower-level details of adapter initialization. Then we have another if condition, which is very puzzling because it seems unneeded and, again, deals with adapter. Then we have one line of code which is (finally) related to the abstraction of feed items and uses the list passed into the method, followed by some heavily commented and obscure lower-level code. All in all, there is just one line of code inside this method directly related to the level of abstraction specified in its name. Dirty.

That’s what I’ve got after the refactoring according to SLAP:

    private fun showFeedItems(list: List<Any>?) {
        ensureFeedUiInitialized()
        bindFeedItemsToAdapter(list ?: emptyList())
        reportFullyDrawnAfterFeedItemsDrawn()
    }

    private fun ensureFeedUiInitialized() {
        if (adapter == null) {
            val viewBinders = initializeViewBinders()
            adapter = FeedAdapter(viewBinders)
        }
        if (recyclerView.adapter == null) {
            recyclerView.adapter = adapter
        }
    }

    private fun initializeViewBinders() {
        val sectionHeaderViewBinder = FeedSectionHeaderViewBinder()
        val countdownViewBinder = CountdownViewBinder()
        val momentViewBinder = MomentViewBinder(
            eventListener = model,
            userInfo = model.userInfo,
            theme = model.theme
        )
        val sessionsViewBinder = FeedSessionsViewBinder(model)
        val feedAnnouncementsHeaderViewBinder =
            AnnouncementsHeaderViewBinder(this, model)
        val announcementViewBinder = AnnouncementViewBinder(model.timeZoneId, this)
        val announcementsEmptyViewBinder = AnnouncementsEmptyViewBinder()
        val announcementsLoadingViewBinder = AnnouncementsLoadingViewBinder()
        val feedSustainabilitySectionViewBinder = FeedSustainabilitySectionViewBinder()
        val feedSocialChannelsSectionViewBinder = FeedSocialChannelsSectionViewBinder()

        @Suppress("UNCHECKED_CAST")
        val viewBinders = ImmutableMap.builder<FeedItemClass, FeedItemBinder>()
            .put(
                sectionHeaderViewBinder.modelClass,
                sectionHeaderViewBinder as FeedItemBinder
            )
            .put(
                countdownViewBinder.modelClass,
                countdownViewBinder as FeedItemBinder
            )
            .put(
                momentViewBinder.modelClass,
                momentViewBinder as FeedItemBinder
            )
            .put(
                sessionsViewBinder.modelClass,
                sessionsViewBinder as FeedItemBinder
            )
            .put(
                feedAnnouncementsHeaderViewBinder.modelClass,
                feedAnnouncementsHeaderViewBinder as FeedItemBinder
            )
            .put(
                announcementViewBinder.modelClass,
                announcementViewBinder as FeedItemBinder
            )
            .put(
                announcementsEmptyViewBinder.modelClass,
                announcementsEmptyViewBinder as FeedItemBinder
            )
            .put(
                announcementsLoadingViewBinder.modelClass,
                announcementsLoadingViewBinder as FeedItemBinder
            )
            .put(
                feedSustainabilitySectionViewBinder.modelClass,
                feedSustainabilitySectionViewBinder as FeedItemBinder
            )
            .put(
                feedSocialChannelsSectionViewBinder.modelClass,
                feedSocialChannelsSectionViewBinder as FeedItemBinder
            )
            .build()

        return viewBinders
    }

    private fun bindFeedItemsToAdapter(feedItems: List<Any>) {
        (binding.recyclerView.adapter as FeedAdapter).submitList(feedItems)
    }

    private fun reportFullyDrawnAfterFeedItemsDrawn() {
        // After submitting the list to the adapter, the recycler view starts measuring and drawing
        // so let's wait for the layout to be drawn before reporting fully drawn.
        binding.recyclerView.doOnLayout {
            // reportFullyDrawn() prints `I/ActivityTaskManager: Fully drawn {activity} {time}`
            // to logcat. The framework ensures that the statement is printed only once for the
            // activity, so there is no need to add dedupping logic to the app.
            activity?.reportFullyDrawn()
        }
    }

After this refactoring, it becomes very clear what happens when we want to show feed items. Most of the code now resides inside a method that clearly states that it deals with “view binders” abstraction, so you can skip it when reading the code if you’re not interested in this level of details. Note how this initializeViewBinders method is still relatively long, but it’s totally fine because it deals with just one level of abstraction. Breaking this method up wouldn’t actually make sense because that would fragment the composition of Map data structure for no gain. Therefore, following SLAP in this case produced the optimal code automatically.

Just like before, for the record, I’ll say that the code after refactoring still isn’t clean. Experienced Android developers will notice that the way they handle adapter is nonsensical and there are unneeded nullable types in the code, but none of that is related to SLAP.

Conclusion

Single Level of Abstraction principle is exceptionally powerful heuristic for writing clean code. It leads to simpler, more readable and more reusable code, while also freeing you from thinking about the length of your methods.

The benefits of SLAP become even more pronounced when you refactor legacy code because SLAP guides you towards many small, incremental refactorings that you can easily implement. These small steps will lead to a compound interest over time and can turn into major architectural improvements.

Since “abstractions” in your code aren’t defined precisely, SLAP is not an exact science. Different developers can infer different levels of abstractions from the same requirements and can end up with different code structures. That’s totally fine. It’s much more important to be mindful about the abstractions in your code than to have a consensus over every one of them.

When you just start using SLAP, you’ll need to invest energy and attention to apply it when you write code. However, after a while, this process will become automated and you’ll start writing cleaner code without any additional effort. So, I highly recommend that you start using Single Level of Abstraction Principle right now and stick to it going forward.

Check out my premium

Android Development Courses

4 comments on "Your Methods Should be “Single Level of Abstraction” Long"

  1. Hi Vasiliy,

    You made the follow method reportFullyDrawnAfterFeedItemsDrawn() as a suspending function ? It doesn’t seem right to me 🙂 What I learnt from your course is that a “suspend functions should be called only from a coroutine or another suspend function”. Or am I missing something ?

    Thanks

    Reply
    • Hey,
      You’re absolutely correct: this suspend modifier doesn’t make sense. I added it by mistake. Removed not. Thanks for pointing this out.

      Reply
  2. Nice article! I wonder how this fits in Jetpack Compose, where a lot of “logic” code is intermingled with the UI parts, without adding a ton of complexity.

    Reply
  3. Excellent examples!

    Robert Martin also mentions this idea in “Clean Code”, but not until after proclaiming “the first rule of functions” to be that they should be short, which as you point out often follows from SLAP but not always. And he missed the opportunity to give it a snappy acronym. 🙂

    Reply

Leave a Comment