You've seen two implementations of calisthenics objects that differed in many ways, both in the way they applied the rules and in the solutions they proposed to meet the rules discussed in this article:
To back up our comments, we wanted to ask other developers for their views on these rules. You'll see that even in this case, we still have different points of view, and that's what's so great!
If you didn't read it, we recommend you to do it:
Each participant decided to apply the rules in his or her own way: either by solving the exercise, or by commenting on the solutions presented. The result is an open-minded approach to the different points of view that can result in totally different applications of these rules.
Happy reading!
Feedback from other participants
Jonathan Roellinger
Step 1: Creating Unit Tests with Copilot
I began by asking Copilot to create unit tests for all our services.
[Fact]
public void AddMember_ShouldAddCharacter_WhenValid()
{
// Arrange
var service = new FellowshipOfTheRingService();
var character = new Character
{
N = "Frodo",
R = "Hobbit",
W = new Weapon { Name = "Sting", Damage = 10, },
C = "Shire",
};
// Act
var act = () => service.AddMember(character);
// Assert
act.Should().NotThrow();
service.ToString().Should().Contain("Frodo (Hobbit) with Sting");
}
My goal was to achieve 100% coverage, even though I know coverage isn't the ultimate measure of test quality.
Step 2: Enhancing Tests with Theory and InlineData
I refactored the test methods in FellowshipOfTheRingServiceTests
to use Theory
and InlineData
attributes.
[Theory]
[InlineData("Frodo", "Hobbit", "Sting", 10, "Shire", "Frodo (Hobbit) with Sting in Shire")]
[InlineData("Aragorn", "Human", "Sword", 100, "Rivendell", "Aragorn (Human) with Sword in Rivendell")]
public void AddMember_ValidCharacter_AddsCharacter(
string name,
string race,
string weaponName,
int damage,
string region,
string expectedOutput)
{
// Arrange
var service = new FellowshipOfTheRingService();
var character = new Character
{
N = name,
R = race,
W = new Weapon { Name = weaponName, Damage = damage, },
C = region,
};
// Act
service.AddMember(character);
// Assert
service.ToString().Should().Contain(expectedOutput);
}
This change allowed for parameterized testing, making our tests more flexible and comprehensive. I replaced several Fact
attributes with Theory
and InlineData
, which improved test coverage and readability.
Step 3: Refactoring the Character Class
I decided to improve the Character
class by using more descriptive property names:
N
becameName
R
becameRace
W
becameWeapon
C
becameCurrentRegion
I also modified the FellowshipOfTheRingService
to accommodate these changes and added new functionality, including validation methods and uniqueness checks for character names.
public void AddMember(Character character)
{
ValidateCharacter(character);
EnsureUniqueCharacterName(character.Name);
_members.Add(character);
}
Step 4: Implementing the Result Pattern
Instead of throwing exceptions, I implemented the Result
pattern to handle errors more elegantly. I used T.D.D
for this process, starting with red tests, then moving to green tests, and finally refactoring.
public Result AddMember(Character character)
{
if (character == null)
{
return Result.Failure("Character cannot be null.");
}
...
return Result.Success();
}
This step involved updating our Program.cs
and test files to align with the new error handling approach.
Step 5: Introducing the Fellowship Class
I created a new Fellowship
class to manage character-related operations. This process involved:
- Writing red tests in
FellowshipTests
- Implementing the
Fellowship
class with methods likeAddMember
,GetCharacterByName
, andMoveMembersToRegion
- Refactoring existing code to use the new
Fellowship
class
public sealed class FellowshipOfTheRingService
{
private readonly Fellowship _fellowship = new();
public Result AddMember(Character character) => _fellowship.AddMember(character);
...
}
public sealed class Fellowship
{
private readonly List<Character> _members = new();
public Result AddMember(Character character)
{
...
if (_members.Exists(m => m.Name == character.Name))
{
return Result.Failure($"A character with the name '{character.Name}' already exists in the fellowship.");
}
_members.Add(character);
return Result.Success();
}
...
}
Step 6: Adding a Golden Test and Introducing FellowshipManager
Realizing the importance of testing Program.cs
, I added a golden test to ensure it works as expected. I also introduced a FellowshipManager
class to encapsulate character and region management logic, further improving our code structure.
Dorra Bartaguiz
Dorra, who initiated this series of articles following his post on LinkedIn, added a few details on his return from the exercise.
It's important to specify that these rules are not applied to all the application code, but mainly to the business code found in the Domain part of the hexagonal architecture. We have 100% control over this part, which is our core business, unlike other parts such as Infrastructure, where we call on external elements and libraries.
The link between the business code and the infrastructure code can be made via DTOs (Data Transfer Objects), on which these rules do not apply, since these objects are used solely for data transport.
Another important point: exceptions in constructors. I consider that if a constructor is called, everything is valid and the object is ready to be created. So there's no need to run any checks here on the Character
object, since it's already been created. As the object has been created, it is considered valid.
public void AddMember(Character character)
{
if (character == null)
{
throw new ArgumentNullException(nameof(character), "Character cannot be null.");
}
else if (string.IsNullOrWhiteSpace(character.N))
{
throw new ArgumentException("Character must have a name.");
}
}
If you have validation rules for the construction of an object, it's best to create a factory method and set the constructor to private. This protects the object's creation by ensuring that it respects these validation rules.
Clément Sannier
Same as Yoan and Pierre, my first task is to create a unit test. I'm used to using Approval test nuget package but, i'm happy to discover other package like Verify. The first question i asked myself was how i was going to create this test to avoid breaking anything. I think that the best is to move the program code in another class to run it and connect Verify.
[Fact]
public async Task Should_Retrieve_Initial_Result()
{
var stringWriter = new StringWriter();
Console.SetOut(stringWriter);
FellowshipOfTheRingApp.Run();
var output = stringWriter.ToString();
Console.SetOut(Console.Out);
await Verify(output);
}
During this refactoring, i used Rider and i configure it to enable continuous testing.
That aside, I always start a refactoring by the "Don't Abbreviate" rule. I often rename some fields to clarify the code. It's the moment for me to understand the code and decide the next task.
I don't think the rules need a precise order. I judge the order as i go along. In this example, the second problem is the chain of if-else statements in AddMember to verify Character.
So I begin by using the "Keep All Entities Small" rule to extract verification from the Character factory :
public static Character Create(string name, string race, Weapon weapon)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Character must have a name.");
}
if (string.IsNullOrWhiteSpace(race))
{
throw new ArgumentException("Character must have a race.");
}
if (weapon == null)
{
throw new ArgumentException("Character must have a weapon.");
}
if (string.IsNullOrWhiteSpace(weapon.Name))
{
throw new ArgumentException("A weapon must have a name.");
}
if (weapon.Damage <= 0)
{
throw new ArgumentException("A weapon must have a damage level.");
}
return new Character(name, race, weapon);
}
and then i introduce the rules "Wrap All Primitives and Strings" to optimise the code :
public static Character Create(string name, string race, Weapon weapon)
{
return new Character(Name.Create(name), Race.Create(race), weapon);
}
I apply the two rules separately to handle any feedback errors in my test with Verify. It's like baby steps in TDD.
These 2 rules lead to the use of the rules: "Don't Use the ELSE Keyword" and "Only One Level of Indentation per Method."
When this rules have been applied, i can iterate on AddMember to apply a new rule. In this case, i can apply the "First Class Collections" on members.
At this point, i think that AddMember is refactored for me :
public void AddMember(Character character)
{
_fellowshipOfTheRingMembers.AddMember(character);
}
I can switch to another method. I've already refactored RemoveMember() with the last iteration. So, I can now start to refactor MoveMembersToRegion and iterate again.
Guillaume Faas
Pierre and Yoan began the exercise from the same starting point but followed widely different paths while trying to stick to the same set of rules. As shown in the conclusion of Chapter 4, the order of implementation entirely differs from one another, and if you had asked me, I would have prioritized things differently, too. Why is that?
Not all rules carry the same weight, and their importance will change depending on the individual. Object Callisthenics shouldn't be viewed as a strict ruleset to adhere to but rather as suggestions to help make your code easier to manage. You're free to disregard or bend some rules - it's perfectly fine.
Certain rules, though, carry more influence than others. For instance, "No Getters/Setters/Properties", "First Class Collections", "Wrap All Primitives and Strings", "Keep All Entities Small", and "One Dot per Line" (Law of Demeter) profoundly affect how components are structured and interact - their scope of action is huge. These rules lead to better design, encapsulation, and domain representation, making them first-class citizens.
The other rules have a more situational impact. For instance:
- "Don't Abbreviate": This is typically non-negotiable, but abbreviations like "SMS" (Short Message Service) are understandable exceptions, assuming they're common knowledge.
- "Only One Level of Indentation per Method": Let's be clear - the lower the indentation, the better. However, limiting to one level feels somewhat arbitrary.
- Don't Use the ELSE Keyword: While I agree with the goal of early return, sometimes avoiding 'else' isn't feasible. I recently stumbled on that scenario while working on a middleware, and coudln't rely on a ternary operator because both paths didn't return any value - removing 'else' would have made the code awkward.
- No Classes with More Than Two Instance Variables: This can feel restrictive. Some classes genuinely require more data, and forcing fewer variables may add unnecessary complexity. A rule of thumb is to make a class hold only what's necessary.
No matter the situation, good judgment is critical when deciding whether to apply a rule. I don't believe in absolutes; every decision sits in a specific context, and sometimes, following a rule can add more complexity than it removes. Object Callisthenics aims to simplify the code and its interactions; if a rule disrupts that objective, it's probably better to set it aside in that particular scenario.
Object Callisthenics offers a valuable framework, but individual preferences and experiences also play a role - there's more than one way to write good code.
Always with a pinch of salt.
Conclusion
We hope that this series of articles on calisthenics objects has enabled you, firstly, to highlight its rules with the aim of producing a more expressive account, and secondly, to distance yourself from the dogmatism of the single perfect solution and return to a pragmatic approach of different solutions, all with a justified follow-up axis.
Have goat day 🐐
Join the conversation.