
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
}
}
}
}
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
foldnested inside anotherfold. - Inside the inner
fold, we are searching an accumulator list withindexOfFirst, mutating existing elements in place if found, and appending new ones if not. We are usingfoldlike 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 rawIntprimitive and compare it with another rawInt.
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)
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:
- Only one level of indentation per method.
- Do not use the
elsekeyword. - Wrap all primitives and strings.
- First-class collections.
- One dot per line (Law of Demeter).
- Do not abbreviate.
- Keep entities small (under 150 lines).
- No class with more than two instance variables.
- 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

There are two ways developers use Artificial Intelligence today:
- 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.
- 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, andFurnitureAggregationStrategy. 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 classfor zero-cost type safety onFamilyIdandRangeId. Then, encapsulate your raw lists into two First-Class Collections: anImplantedElementscollection that knows how to filter by family/range, and anImplantationInventorycollection 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
}
}
}
}
Why This Design Works
-
Zero
elseKeywords & Single-Level Nesting: The imperative nested loops and list mutations were replaced with immutable functional operations (fold,filter). -
First-Class Collection (
ImplantationInventory): The logic for merging furniture quantities byReferenceIdno longer lives in a random service method. It is isolated insideImplantationInventory, which manages its internal map immutably using the+operator. - The Victory of Tell, Don't Ask: Compare these two lines:
-
Before (Procedural):
implantedElementDetail.rangeChoice.range == furniture.idRange 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.Compile-Time Type Safety: Kotlin's
@JvmInline value classensures we can never accidentally pass aRangeIdinto a parameter expecting aFamilyId, 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.
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.
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.
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
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)