DEV Community

Cover image for Beyond the Dogma: How Object Calisthenics and AI Taught Me to Write Cleaner Kotlin
zerocool
zerocool

Posted on

Beyond the Dogma: How Object Calisthenics and AI Taught Me to Write Cleaner Kotlin

Beyond the Dogma: How Object Calisthenics and AI Taught Me to Write Cleaner Kotlin
A story about store layouts, over-engineering, and finding pragmatic balance with an AI Cognitive Sparring Partner.


Act I: The Layout Puzzle

In spatial store design and merchandise engineering, every millimeter in a retail layout matters. Whether you are placing modular shelving, promotional stands, or store fixtures, physical space has strict rules.

In software architecture, our domain models should reflect those same strict rules. Yet, domain logic often ends up scattered across procedural service classes.

A while ago, I was working on our store mapping services—specifically, a feature responsible for calculating the final inventory requirements of furniture fixtures for a seasonal store layout.

I opened the service and found a method responsible for matching fixture families with furniture ranges and aggregating their total quantities. What greeted me was a classic piece of procedural logic:

class StoreFurnitureService {

    fun calculateImplantationFurnitures(
        families: List<Int>,
        implantableElements: List<TreeNodeLeaf>,
        furnitures: List<Furniture>
    ): List<ImplantationFurniture> {
        return families.fold(mutableListOf()) { implantationFurnitures, familyId ->
            val implantedElementDetail = implantableElements.find { it.id == familyId }

            if (implantedElementDetail != null) {
                val furnituresDetail = furnitures.filter { furniture ->
                    furniture.idFamily == implantedElementDetail.id &&
                    implantedElementDetail.rangeChoice.range == furniture.idRange
                }

                furnituresDetail.fold(implantationFurnitures) { allFurnitures, furniture ->
                    val addedFurnitureIndex = allFurnitures.indexOfFirst { it.id == furniture.referenceId }

                    if (addedFurnitureIndex > -1) {
                        val existing = allFurnitures[addedFurnitureIndex]
                        allFurnitures[addedFurnitureIndex] = existing.copy(
                            quantity = existing.quantity + furniture.count
                        )
                    } else {
                        allFurnitures.add(
                            ImplantationFurniture(
                                id = furniture.referenceId,
                                quantity = furniture.count,
                                label = furniture.label
                            )
                        )
                    }
                    allFurnitures
                }
            } else {
                implantationFurnitures
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Let's pause and pair-program on this snippet for a second. Read lines 16–28 again.

Notice what is happening here?

  • We have a fold nested inside another fold.
  • Inside the inner fold, we are searching an accumulator list with indexOfFirst, mutating existing elements in place if found, and appending new ones if not. We are using fold like an imperative loop with state side-effects.
  • Look closely at the filter condition: implantedElementDetail.rangeChoice.range == furniture.idRange. The service layer is directly reaching three levels deep into a tree node to inspect a raw Int primitive and compare it with another raw Int.

The service is asking objects for their raw data and performing list arithmetic on their behalf. The domain models (Furniture, TreeNodeLeaf) are acting as mere passive data buckets—a textbook example of an Anemic Domain Model violating Tell, Don't Ask and the Law of Demeter.


Act II: Peak Dunning-Kruger (The Over-Engineering Trap)

Dunning-Kruger - object calisthenics

I wanted to refactor this logic into a clean, rich Kotlin domain model. I remembered Jeff Bay’s 9 rules of Object Calisthenics from The ThoughtWorks Anthology:

  1. Only one level of indentation per method.
  2. Do not use the else keyword.
  3. Wrap all primitives and strings.
  4. First-class collections.
  5. One dot per line (Law of Demeter).
  6. Do not abbreviate.
  7. Keep entities small (under 150 lines).
  8. No class with more than two instance variables.
  9. No getters/setters/properties (Tell, Don't Ask).

"I am going to apply every single rule strictly," I thought.

An hour later, I looked at my changes: I had created 10 separate abstractions—FamilyId, RangeId, FurnitureReferenceId, FurnitureQuantity, FurnitureLabel, FurnitureMatcher, RangeMatchingSpecification, FurnitureAggregationStrategy, and so on.

I had created a maze of single-method wrappers and strategy interfaces just to match a furniture range and add two numbers together. To trace how a single piece of furniture was counted, I had to navigate through layers of abstractions.

I had eliminated the nested branches and wrapped every Int, but I had introduced massive accidental complexity. I had fallen into the Dunning-Kruger trap: confusing dogmatic compliance with good software design.

The Calisthenics Cheat Sheet: From Smell to Idiomatic Kotlin

Object Calisthenics Rule Procedural Code Smell Modern Kotlin Solution
Rule 1: 1 Level of Indentation Deeply nested loops and conditionals fold, map, filter, and early returns
Rule 2: No else Keyword Branching state machines Guard clauses (require, check), when expressions
Rule 3: Wrap Primitives Primitive Obsession (Int, String) @JvmInline value class (zero-cost allocation)
Rule 4: First-Class Collections Leaky, mutable List<T> operations Dedicated domain wrapper classes
Rule 9: Tell, Don't Ask Anemic Data Holders Rich Domain Entities with behavior methods

Act III: Enter the AI Cognitive Sparring Partner

AI Cognitive Sparring Partner
There are two ways developers use Artificial Intelligence today:

  1. The Viber's Way: Asking AI to "refactor this method using Object Calisthenics." The AI will happily generate 15 files of boilerplate, amplifying your over-engineering and drowning your domain in accidental complexity.
  2. The Craftsman Way: Using AI as a Cognitive Sparring Partner—a concept championed by software craftspeople. You don't ask AI to think for you; you ask it to challenge your design choices and expose your blind spots.

I invoked a custom prompt in my IDE named calisthenics-challenger, pasted my 10 new classes, and asked:

"Play devil's advocate. I strictly applied Object Calisthenics to this furniture aggregation logic. Where did I over-engineer this? Where did I create class explosion instead of domain clarity?"

The AI’s response was an immediate reality check:

"You created FurnitureMatcher, RangeMatchingSpecification, and FurnitureAggregationStrategy. None of these abstractions represent distinct business concepts in your domain—they are procedural logic dressed up in strategy patterns.
In Kotlin, you don't need heavy wrapper objects for every primitive ID. Use @JvmInline value class for zero-cost type safety on FamilyId and RangeId. Then, encapsulate your raw lists into two First-Class Collections: an ImplantedElements collection that knows how to filter by family/range, and an ImplantationInventory collection that owns the accumulation logic via operator overloading."

That pushback changed my perspective. The rules of Object Calisthenics were never meant to be dogmatic laws to follow blindly; they were training wheels designed to build muscle memory around encapsulation and cohesion.


Act IV: The Pragmatic Kotlin Solution

With the AI serving as a guardrail against over-engineering, we refactored the store furniture implantation logic into expressive, idiomatic Kotlin.

Here is the result:

// Rule 3: Zero-allocation Value Objects via Kotlin Inline Classes
@JvmInline
value class FamilyId(val value: Int)

@JvmInline
value class RangeId(val value: Int)

@JvmInline
value class ReferenceId(val value: Int)

@JvmInline
value class Quantity(val count: Int) {
    init {
        require(count >= 0) { "Quantity cannot be negative." }
    }

    // Idiomatic Kotlin operator overloading for domain arithmetic
    operator fun plus(other: Quantity): Quantity = Quantity(this.count + other.count)
}

// Domain Entity encapsulating Furniture characteristics
data class Furniture(
    val referenceId: ReferenceId,
    val familyId: FamilyId,
    val rangeId: RangeId,
    val label: String,
    val count: Quantity
) {
    // Encapsulated domain logic matching range constraints
    fun matchesRange(family: FamilyId, range: RangeId): Boolean =
        this.familyId == family && this.rangeId == range
}

// Rich Value Object representing a matched element in the layout
data class ImplantedElementDetail(
    val id: FamilyId,
    val selectedRange: RangeId
)

// Rule 4: First-Class Collection encapsulating Store Furniture Inventory
class ImplantationInventory private constructor(
    private val items: Map<ReferenceId, ImplantationItem>
) {
    data class ImplantationItem(
        val referenceId: ReferenceId,
        val label: String,
        val quantity: Quantity
    )

    constructor() : this(emptyMap())

    // Immutable collection aggregation using operator overloading
    operator fun plus(furniture: Furniture): ImplantationInventory {
        val existing = items[furniture.referenceId]
        val updatedQuantity = existing?.quantity?.let { it + furniture.count } ?: furniture.count
        val updatedItem = ImplantationItem(furniture.referenceId, furniture.label, updatedQuantity)

        return ImplantationInventory(items + (furniture.referenceId to updatedItem))
    }

    fun toList(): List<ImplantationItem> = items.values.toList()
}

// Rule 4: First-Class Collection managing the Catalog of Furnitures
class FurnitureCatalog(private val furnitures: List<Furniture>) {

    // Rules 1, 2 & 9: Flat processing adhering to Tell, Don't Ask
    fun calculateImplantationRequirements(
        implantedElements: List<ImplantedElementDetail>
    ): ImplantationInventory {
        return implantedElements.fold(ImplantationInventory()) { inventory, element ->
            val matchingFurnitures = furnitures.filter { furniture ->
                furniture.matchesRange(element.id, element.selectedRange)
            }
            matchingFurnitures.fold(inventory) { currentInventory, furniture ->
                currentInventory + furniture
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Why This Design Works

  1. Zero else Keywords & Single-Level Nesting: The imperative nested loops and list mutations were replaced with immutable functional operations (fold, filter).
  2. First-Class Collection (ImplantationInventory): The logic for merging furniture quantities by ReferenceId no longer lives in a random service method. It is isolated inside ImplantationInventory, which manages its internal map immutably using the + operator.
  3. The Victory of Tell, Don't Ask: Compare these two lines:
  4. Before (Procedural): implantedElementDetail.rangeChoice.range == furniture.idRange
  5. After (Crafted): furniture.matchesRange(element.id, element.selectedRange)
    We stopped reaching three levels deep into internal object structures and started delegating intent directly to the entity.

  6. Compile-Time Type Safety: Kotlin's @JvmInline value class ensures we can never accidentally pass a RangeId into a parameter expecting a FamilyId, with zero runtime allocation overhead.


Act V: Your AI Sparring Toolkit (4 Custom Skills)

To integrate this workflow into your daily routine—whether using Claude Code, Cursor, or custom assistant prompts—here are the four AI Skills formatted for your IDE environment.

Skill 1: calisthenics-suggester

---
name: calisthenics-suggester
description: Analyzes legacy or complex Kotlin code for Object Calisthenics violations and proposes a clean domain refactoring plan. Use when staring at a complex method, deep nested branches, primitive obsession, or anemic domain logic.
---

## Calisthenics Opportunity Suggester

Identifies structural code smells in Kotlin code using Object Calisthenics as a diagnostic tool, then proposes a pragmatic refactoring strategy.

When exploring the codebase, read `CONTEXT.md` (if it exists) to match domain language.

## Diagnostic Process

1. **Scan for Core Violations:**
* **Indentation & Branching:** Methods with >1 level of nesting or reliance on `else` keywords.
* **Primitive Obsession:** Raw `Int`, `String`, or `Double` types carrying business invariants without validation.
* **Naked Collections:** Direct list manipulation (`filter`, `map`, `reduce`, `indexOfFirst`) inside service classes instead of dedicated containers.
* **Anemic Domain Entities:** Classes that act as passive data buckets while services execute domain math on their behalf (*Tell, Don't Ask* breach).


2. **Formulate Refactoring Strategy:**
* Group raw primitives into Kotlin `@JvmInline value class` abstractions.
* Extract raw collection operations into First-Class Collections.
* Convert nested conditional logic into early-exit guard clauses (`require`, `check`).


3. **Output Format:**
* **Smells Identified:** Quote the exact lines violating encapsulation.
* **Domain Concept to Surface:** Name the hidden business entity or value object wanting to be born.
* **Refactored Kotlin Draft:** Provide concise, high-signal Kotlin code implementing the fix.
Enter fullscreen mode Exit fullscreen mode

Skill 2: calisthenics-challenger

---
name: calisthenics-challenger
description: Acts as a pragmatic Tech Lead to challenge proposed refactorings. Prevents over-engineering, class explosion, and YAGNI violations. Use before committing a refactoring or when evaluating if a Calisthenics abstraction is worth the complexity.
---

# Calisthenics Challenger (The Cognitive Sparring Partner)

Prevents dogmatic compliance with Object Calisthenics from turning clean code into over-engineered accidental complexity.

## The Sparring Framework

When presented with a proposed refactoring, audit it against these four anti-patterns before approving:

1. **Class Explosion:** Creating 5+ single-method interfaces, strategy patterns, or wrapper classes for a simple requirement. Use Kotlin's `@JvmInline value class` for zero-allocation type safety instead of heavy wrapper objects.
2. **Speculative Generality (YAGNI):** Adding generic parameters, strategy interfaces, or factory wrappers for "future extensibility" not asked for by the domain.
3. **Anemic Abstractions:** Creating a Value Object that wraps a primitive but contains zero validation logic or domain behavior.
4. **Broken Navigation:** Requiring a developer to jump across 8 different small files to trace a single 20-line feature.

## Response Process

1. **Acknowledge the Intent:** State what the refactoring was trying to solve.
2. **Push Back Explicitly:** Point out exactly where dogmatic rule compliance created noise or class explosion.
3. **Offer the Pragmatic Balance:** Show a simplified Kotlin alternative that retains domain encapsulation without file clutter.
Enter fullscreen mode Exit fullscreen mode

Skill 3: boyscout-refactor

---
name: boyscout-refactor
description: Scopes down refactoring after completing a feature or bugfix. Identifies exactly ONE low-risk Object Calisthenics improvement on touched files without expanding scope. Use right after passing feature tests before opening a PR.
---

# Boy Scout Refactor

Applies the Boy Scout Rule (*"Leave the code cleaner than you found it"*) in surgical, low-risk vertical slices.

## Rules of Engagement

1. **Scope Limit:** Examine ONLY files touched in `git diff HEAD~1` or current working tree.
2. **Single Improvement Rule:** Identify and execute **exactly ONE** high-value refactoring. Do not attempt a full system rewrite.
3. **Zero Behavior Change:** The refactoring must preserve existing test coverage. Run tests before and after.

## Priority Checklist (Pick ONE)

* [ ] **Guard Clauses:** Replace a nested `if/else` block with an early `require()` or `check()` guard clause.
* [ ] **First-Class Collection:** Extract a raw list/map filtering pipeline into a dedicated First-Class Collection method.
* [ ] **Value Class:** Convert a naked domain parameter (e.g., `familyId: Int`) into a Kotlin `@JvmInline value class`.
* [ ] **Tell, Don't Ask:** Move a calculation from a service method directly onto the entity holding the data.
Enter fullscreen mode Exit fullscreen mode

Skill 4: craft-code-review

---
name: craft-code-review
description: Two-axis code review evaluating diffs along Standards (Object Calisthenics & Kotlin Idioms) and Spec (PRD/Issue requirements). Use during pull request review or before merging work-in-progress.
---

# Craft Code Reviewer

Executes a two-axis review comparing the current diff against **Standards** (Software Craftsmanship / Object Calisthenics) and **Spec** (Issue / Business Requirements).

## Process

1. **Identify Context:** Capture diff via `git diff <fixed-point>...HEAD`. Read `CONTEXT.md` for domain terminology.
2. **Standards Axis:** Evaluate diff against Calisthenics rules (Indentation depth, `else` keyword, primitive obsession, naked collections, *Tell, Don't Ask*, and over-engineering).
3. **Spec Axis:** Verify acceptance criteria, check for missing edge cases or scope creep.

## Output Format

Present findings under separate headings without reranking:

### Standards Findings

* [Rule/Smell Name]: Quote hunk & suggest Kotlin fix.

### Spec Findings

* [Requirement ID/Line]: Missing or incorrectly implemented behavior.

### Summary

* Standards: X findings | Spec: Y findings
Enter fullscreen mode Exit fullscreen mode

Continuous, Intentional Learning

As Robert Heinlein wrote, "When one teaches, two learn."

Software craftsmanship isn't about collecting tools, chasing trendy frameworks, or blindly following 18-year-old rules. It is about understanding the underlying domain problem deeply enough to choose the right trade-offs for that exact context.

When you combine disciplined constraints like Object Calisthenics with modern Kotlin idioms and an AI assistant that actively challenges your design decisions, you stop writing clever code—and start building software that makes tomorrow easier than today.


What are your favorite Object Calisthenics rules to use (or break)?

Top comments (0)