Telerik blogs

Object Calisthenics is a set of nine rules that challenges the way we think about and write object-oriented code. See how they can help evolve our code.

Despite the rapid evolution of frameworks, a recurring problem persists in modern projects: object-oriented code featuring entities riddled with public properties, business logic scattered across services, duplicated validation logic and a domain model that fails to accurately represent the problem the system aims to solve. While this type of code may function, it tends to become increasingly difficult to evolve, test and maintain as the application grows.

It is in this context that Object Calisthenics remains relevant. Created as a set of exercises to foster better object-oriented programming habits, these rules are not meant to be followed rigidly. Instead, they exist to challenge how we model our objects and to encourage us to write code that is cohesive, expressive and characterized by well-defined responsibilities.

In this article, we will explore the nine rules proposed by Object Calisthenics, understand the rationale behind each one and analyze how they remain relevant for modern applications built with C# and ASP.NET Core.

What Are Object Calisthenics?

Object Calisthenics are a set of nine rules proposed by Jeff Bay and published originally in 2008 in the book The ThoughtWorks Anthology.

The term “calisthenics” refers to physical exercises, performed using one’s own body weight to build strength, coordination and discipline. The idea behind Object Calisthenics follows this same philosophy: rather than teaching new language features, it proposes a series of constraints that encourage developers to practice better object-oriented modeling.

It is important to note that these rules should not be followed absolutely. They were created as an exercise to foster good design habits. By limiting certain practices such as the excessive use of long method chains between objects, developers are encouraged to reflect more deeply on each class’s responsibilities and how objects collaborate. This can be compared to Test-Driven Development (TDD), which encourages programmers to start with tests but does not impose an absolute rule.

Although the rules were proposed many years ago, the problems they aim to solve persist in modern applications. We still encounter anemic domain models, classes with too many responsibilities and tight coupling between objects. Object Calisthenics offers a practical way to identify these issues and encourage solutions driven by behavior rather than data.

Getting to Know the 9 Rules

In the following sections, we will analyze each rule individually, understand its rationale and discuss how it can be applied or adapted in modern applications developed with C# and ASP.NET Core.

The nine rules are:

  1. One Level of Indentation Per Method
  2. Don’t Use the ELSE Keyword
  3. Wrap All Primitives and Strings
  4. First-Class Collections
  5. One Dot Per Line
  6. Don’t Abbreviate
  7. Keep All Entities Small
  8. No Classes with More Than Two Instance Variables
  9. No Getters/Setters/Properties

All code examples covered in the post are available in this repository: Practicing Object Calisthenics.

1. One Level of Indentation Per Method

The first rule recommends that a method have only one level of indentation. Whenever we encounter multiple nested blocks (such as if, foreach, while, try, etc.), it usually indicates that the method is taking on too many responsibilities. Excessive indentation makes the execution flow harder to understand and increases complexity, making code maintenance more difficult.

Consider the following example of a service responsible for approving loan requests.

❌ Before

public async Task ApproveAsync(LoanApplication application)
{
  if (application.Status == LoanStatus.Pending)
  {
    if (application.Customer.IsActive)
    {
      if (application.Customer.CreditScore >= 700)
      {
        var debt = await _debtRepository.GetOutstandingDebtAsync(application.Customer.Id);
          if (debt == 0)
          {
            application.Approve();
              await _loanRepository.SaveAsync(application);
              await _notificationService.SendApprovalEmailAsync(application.Customer.Email);
          }
      }
    }
  }
}

Although the code works, the flow is buried within four levels of indentation. To understand what is happening, the reader must trace through all the conditional blocks to find the core logic, keeping track of the paths already traversed.

A more object-oriented approach is to delegate some decisions to smaller methods and use guard clauses to halt execution when a precondition is not met.

✅ After

public async Task ApproveAsync(LoanApplication application)
{
    var canBeApproved = await CanBeApproved(application);

    if (!canBeApproved)
        return;

    await ApproveLoan(application);
}

private async Task<bool> CanBeApproved(LoanApplication application) 
{ 
    if (application.Status != LoanStatus.Pending) 
        return false; 
    
    if (!application.Customer.IsActive) 
        return false; 

    if (application.Customer.CreditScore < 700) 
        return false; 

    var debt = await _debtRepository.GetOutstandingDebtAsync(application.Customer.Id); 
    
    return debt == 0;
}

private async Task ApproveLoan(LoanApplication application) 
{ 
    application.Approve(); 
    
    await _loanRepository.SaveAsync(application); 
    
    await _notificationService.SendApprovalEmailAsync(application.Customer.Email); 
}

Note that the behavior of the ApproveAsync method remains exactly the same, but now each helper method has a single responsibility. The ApproveAsync method clearly demonstrates the operation’s intent, while CanBeApproved encapsulates the eligibility rules and ApproveLoan performs only the approval itself.

The main goal of this rule is to avoid creating methods that attempt to do too many things at once. When evaluating each method individually, it is generally easier to understand the overall flow. Therefore, using concise methods is preferable.

2. Don’t Use the ELSE Keyword

The second rule suggests avoiding the else keyword. The idea is not that else is a bad language feature, but rather that its necessity often indicates a method is handling two different flows simultaneously.

To adhere to this rule, we can use strategies such as early returns or even polymorphism whenever there are multiple conditional behaviors.

Consider the method that cancels a loan request.

❌ Before

public async Task Cancel(LoanApplication application)
 {
     if (application.Status == LoanStatus.Pending)
     {
         application.Cancel();

         await _loanRepository.SaveAsync(application);

         await _notificationService.SendCancellationEmailAsync(application.Customer.Email);
     }
     else
     {
         _logger.LogWarning("Loan {LoanId} cannot be cancelled because it is {Status}.",
             application.Id,
             application.Status);
     }
 }

The Cancel method has two completely different flows: one for a successful operation and another for handling an invalid condition.

We can make this flow more streamlined by using a guard clause, a technique that involves checking for error conditions or special cases right at the beginning of a function or method, returning a value or throwing an error early if the requirements are not met.

✅ After

public async Task Cancel(LoanApplication application)
 {
     if (application.Status != LoanStatus.Pending)
     {
         _logger.LogWarning(
             "Loan {LoanId} cannot be cancelled because it is {Status}.",
             application.Id,
             application.Status);

         return;
     }

     application.Cancel();

     await _loanRepository.SaveAsync(application);

     await _notificationService.SendCancellationEmailAsync(application.Customer.Email);
 }

The method now presents only the operation’s main flow. The exceptional condition is handled right at the start, and execution stops immediately, making an else block unnecessary. This approach improves readability and clearly highlights the operation’s “happy path.”

It is important to note that this rule does not dictate that else should never be used. There are situations where using it makes the code clearer, such as in simple expressions (like the ternary operator ?:) or when two flows are of equal importance. The goal is to encourage methods with an easy-to-read flow, rather than eliminating else at all costs.

3. Wrap All Primitives and Strings

The third rule recommends encapsulating primitive types (int, decimal, string, DateTime, etc.) in objects that represent domain concepts.

Although primitive types are simple, they can make the system much harder to understand because they do not enforce business rules. Consequently, any value valid for the type also becomes valid for the domain, even in situations where it makes no sense.

Consider the example below.

❌ Before

public class LoanApplication
{
    public decimal Amount { get; private set; }

    public decimal InterestRate { get; private set; }

    public int Installments { get; private set; }

    public LoanApplication(
        decimal amount,
        decimal interestRate,
        int installments)
    {
        Amount = amount;
        InterestRate = interestRate;
        Installments = installments;
    }
}

This example presents a classic case of using primitive types. At first glance, there is nothing wrong with it. However, this implementation allows the creation of objects with invalid states:

public void ProcessApplication()
 {
     var application = new LoanApplication(
         new Money(50_000), 
         new InterestRate(1.25m),
         new InstallmentCount(48)
     );
 }

The compiler accepts all these values because they are valid for decimal and int, but they make no sense for a loan application, after all, they are negative or zero.

Alternatively, we can encapsulate these concepts in Value Objects.

✅ After

public class LoanApplication
{
    public Money Amount { get; }

    public InterestRate InterestRate { get; }

    public InstallmentCount Installments { get; }

    public LoanApplication(Money amount, InterestRate interestRate, InstallmentCount installments)
    {
        Amount = amount;
        InterestRate = interestRate;
        Installments = installments;
    }
}

public sealed record Money
{
    public decimal Value { get; }

    public Money(decimal value)
    {
        if (value <= 0)
            throw new DomainException("Amount must be greater than zero.");

        Value = value;
    }
}

public sealed record InterestRate
{
    public decimal Value { get; }

    public InterestRate(decimal value)
    {
        if (value < 0 || value > 100)
            throw new DomainException("Interest rate must be between 0 and 100.");

        Value = value;
    }
}

public sealed record InstallmentCount
{
    public int Value { get; }

    public InstallmentCount(int value)
    {
        if (value < 1)
            throw new DomainException(
                "Installment count must be greater than zero.");

        if (value > 120)
            throw new DomainException(
                "Installment count cannot exceed 120.");

        Value = value;
    }
}

Now, each type is responsible for validating its own rules, so invalid objects aren’t created.

In this way, the code comes to represent real-world concepts, such as Money, Interest Rate and Number of Installments.

var application = new LoanApplication(
    new Money(50_000), 
    new InterestRate(1.25m),
    new InstallmentCount(48)
);

Notice that the code becomes much more expressive. We are now creating instances of business concepts rather than simply manipulating numbers and other data. Beyond readability, this approach centralizes validation logic and prevents the creation of objects in invalid states.

4. First-Class Collections

The fourth rule recommends that a collection not be treated merely as a class property. Instead, it is preferable for a collection to have its own behavior.

Based on this rule, we should avoid creating properties that expose a List<T> and allow any part of the application to directly add, remove or manipulate its elements. The problem is that, when this happens, the rules defined for that collection end up scattered throughout the code that uses it.

Consider a loan system where a request consists of multiple installments.

❌ Before

namespace PracticingObjectCalisthenics
{
    public class LoanApplication
    {
        public List<Installment> Installments { get; } = [];

        public void AddInstallment(Installment installment)
        {
            Installments.Add(installment);
        }
    }
}

Note that any code with access to the list can manipulate it directly:

application.Installments.Clear();

application.Installments.Add(new Installment(1, 10, new DateOnly(2026, 9, 10)));

application.Installments.RemoveAt(2);

This means that LoanApplication does not have real control over its own collection.

A possible solution is to turn the collection itself into an object responsible for its own behavior.

✅ After

public class LoanApplication
 {
     private readonly Installments _installments = new();

     public IReadOnlyCollection<Installment> InstallmentItems =>
         _installments.Items;

     public void AddInstallment(Installment installment)
     {
         _installments.Add(installment);
     }
 }
public sealed class Installments
{
    private readonly List<Installment> _items = [];

    public IReadOnlyCollection<Installment> Items =>
        _items.AsReadOnly();

    public void Add(Installment installment)
    {
        if (_items.Count >= 120)
            throw new DomainException(
                "A loan cannot have more than 120 installments.");

        _items.Add(installment);
    }

    public decimal TotalAmount()
    {
        return _items.Sum(x => x.Amount);
    }
}

The (Installments) collection now knows the rules that determine its elements. Consequently, we can no longer add items to it without using its own methods:

application.Installments.Add(installment); // Does not compile

To add an item to the collection now, we need to use its specific method:

application.AddInstallment(installment); // The code compiles now

The main idea behind the First-Class Collections rule is that a collection can be much more than a simple List<T>. You should favor this approach when a collection has its own rules, validations or behaviors that need to be represented in the model.

On the other hand, in scenarios where collections are used to transport data, such as a list used to construct an API response, there is likely no need to apply this rule.

5. One Dot Per Line

The fifth rule recommends preventing an object from knowing the internal structure of other objects. One of the most common signs that code is violating this rule is the presence of chained calls, where an object accesses another object, which accesses another and so on.

Imagine that, during loan approval, we need to record the customer’s city:

❌ Before

public void LogLoanApproval(LoanApplication application)
{
    _logger.LogInformation(
        "Loan {LoanId} approved for customer from {City}.",
        application.Id,
        application.Customer.Address.City.Name);
}

The problem here is not necessarily the number of points, but the fact that LogLoanApproval knows too much about the internal structure of LoanApplication. Thus, we have the following schema: LoanApplication -> Customer -> Address -> City -> Name.

If the structure changes from application.Customer.Address.City.Name to application.Customer.Residence.CityName, this code will also need to be updated.

✅ After

We can delegate the responsibility to the object that holds the information:

public class Customer
{
    public Address Address { get; }
    public string CityName => Address.City.Name;
}
   public void LogLoanApproval(LoanApplication application)
    {
        _logger.LogInformation(
            "Loan {LoanId} approved for customer from {City}.",
            application.Id,
            application.Customer.CityName);
    }

Now, anyone using Customer does not need to know its internal structure. If the implementation of Address or City changes, the impact is confined to the Customer itself.

The goal of this rule is not to eliminate all navigation chains, but rather to prevent external code from depending on the internal structure of other objects, which is why the Address.City.Name chain still exists within Customer. By shifting this knowledge to Customer, only Customer needs to be updated if the internal structure of Address or City changes.

6. Don’t Abbreviate

The sixth rule is probably one of the simplest, yet still essential rules: Do not abbreviate the names of variables, methods, classes or properties.

The idea is to prioritize code clarity. Short names may be faster to type, but the problem is that they usually require more cognitive effort to figure out what the abbreviation means. Consider an example:

❌ Before

public void CalcTot()
 {
     var usr = GetUsr();
     var ords = usr.Orders;
     var tot = 0;

     foreach (var ord in ords)
     {
         var amt = ord.Amount;
         tot += amt;
     }
 }

The code might be quickly understood by the person who wrote it, but the reader has to interpret various abbreviations, such as “CalcTot” for “Calculate Total” and “ords” for “Orders.” There is no real advantage to hiding this information.

If we write the words as they actually are, we get the following:

✅ After

public void CalculateTotal()
 {
     var user = GetUser();
     var orders = user.Orders;
     var total = 0;

     foreach (var order in orders)
     {
         var amount = order.Amount;
         total += amount;
     }
 }

The code can now be understood without any further explanation.

7. Keep All Entities Small

The seventh rule recommends keeping classes and other model elements small. The idea is to avoid objects that accumulate too many responsibilities.

This does not mean that a class with 49 lines, for example, is better than one with 51. Instead, it encourages viewing size as a warning sign: when a class grows too large, there are usually responsibilities that could be transferred to other objects.

Consider a class responsible for representing an order:

❌ Before

public class CustomerOrder
 {
     public int Id { get; set; }

     public List<OrderItem> Items { get; set; } = [];

     public decimal CalculateTotal()
     {
         return Items.Sum(item => item.Price * item.Quantity);
     }

     public void Validate()
     {
         if (Items.Count == 0)
             throw new DomainException("Order must have at least one item.");

         if (Items.Any(item => item.Quantity <= 0))
             throw new DomainException("Invalid item quantity.");
     }

     public void SendConfirmationEmail()
     {
         // Send email...
     }

     public void ExportToCsv()
     {
         // Generate CSV...
     }

     public void Save()
     {
         // Save to database...
     }
 }

The problem here is not the number of lines, but rather the fact that the class has responsibilities belonging to different contexts such as representing the order, calculating the total, validating the order, sending emails, exporting to CSV and persisting data. As new features are added, the Order class tends to grow larger and larger.

To avoid this disproportionate growth, we can keep Order focused on the behavior that truly belongs to the order:

✅ After

public class CustomerOrder
 {
     private readonly List<OrderItem> _items = [];

     public IReadOnlyCollection<OrderItem> Items => _items;

     public decimal CalculateTotal()
     {
         return _items.Sum(item => item.Price * item.Quantity);
     }

     public void AddItem(OrderItem item)
     {
         ArgumentNullException.ThrowIfNull(item);

         _items.Add(item);
     }
 }

The validation can reside in its own component:

public class OrderValidator
{
    public void Validate(CustomerOrder order)
    {
        if (order.Items.Count == 0)
        {
            throw new DomainException("Order must have at least one item.");
        }
    }
}

Email sending can be the responsibility of a dedicated service:

public class OrderNotificationService
{
    public async Task SendConfirmationAsync(Order order)
    {
        // Send email...
    }
}

And persistence is handled by a repository:

public class OrderRepository
{
    public async Task SaveAsync(Order order)
    {
        // Save order record...
    }
}

Now, each class has a much clearer responsibility. It is important to highlight that the true goal is to identify responsibilities that should not be grouped together.

When encountering a very large class, we might ask ourselves: “If I need to change this part of the behavior, how many other responsibilities within this class might be affected?”

If the answer involves different reasons for modifying the class, there is likely an opportunity to separate the responsibilities.

8. No Classes with More Than Two Instance Variables

The eighth rule recommends that a class have at most two instance variables. The intention is not to claim that a class with three fields is automatically poorly designed, but rather to encourage the developer to recognize when a class is accumulating information that could belong to other objects.

Consider a simple class to represent a student:

❌ Before

public class Student
 {
     private string _name;
     private string _email;
     private string _phone;
     private string _document;
     private string _street;
     private string _city;
     private string _state;
 }

The problem here is that the Student class represents multiple domain concepts simultaneously, such as personal information, contact details, identification documents and addresses. We can make these concepts explicit by using dedicated objects.

✅ After

public class Student
 {
     private PersonalInformation _personalInformation;
     private Address _address;
 }

public class PersonalInformation
 {
     private string _name;
     private string _document;
     private ContactInformation _contactInformation;
 }

public class ContactInformation
 {
     private string _email;
     private string _phone;
 }

public class Address
 {
     private string _street;
     private string _city;
     private string _state;
 }

We now have a model where the concepts are more explicit:

- Student
	- PersonalInformation
		- Name
		- Document
		- ContactInformation
			- Email
			- Phone
	- Address
		- Street
		- City
		- State

The most important thing here is not simply reducing the Student class from seven variables to two. The real benefit lies in identifying concepts that were hidden within the class and transforming them into explicit objects. Therefore, this rule should be viewed as a constraint to encourage the discovery of good objects, rather than as a mathematical rule.

9. No Getters/Setters/Properties

The ninth and final rule recommends avoiding the exposure of an object’s internal state via getters, setters or properties without due care.

In this regard, the rule emphasizes that an object should be responsible for its own state and behavior. Instead of allowing any part of the application to modify its properties directly, we should provide methods that express the operations permitted by the domain.

Consider a simple example:

❌ Before

public class LoyaltyAccount
{
    public int Points { get; set; }
}

The problem is that any part of the application can modify the points directly:

account.Points += 100;

account.Points = -500;

In this way, the class has no mechanism to protect its state. A more object-oriented approach is to encapsulate the behavior:

✅ After

public class LoyaltyAccount
 {
     private int _points;

     public int Points => _points;

     public void EarnPoints(int points)
     {
         if (points <= 0)
             throw new DomainException(
                 "Points must be greater than zero.");

         _points += points;
     }

     public void RedeemPoints(int points)
     {
         if (points <= 0)
             throw new DomainException(
                 "Points must be greater than zero.");

         if (points > _points)
             throw new DomainException(
                 "Insufficient points.");

         _points -= points;
     }
 }

Now, the consumer does not need to manipulate the state directly, the object itself decides how these operations should be performed.

var account = new LoyaltyAccount();

  account.EarnPoints(100);

  account.RedeemPoints(50);

  //It doesn't compile now
  //account.Points += 100;

It is worth noting that this rule does not dictate that we should stop using properties altogether. Rather, the goal is to prevent our classes from turning into mere “data bags,” where all the logic ends up scattered across other components.

One question we might ask is: Does the object merely hold data, or does it also need to know what to do with that data? If it does need to know, that is a strong indication that simple properties with only getters and setters should be avoided.

Conclusion

Throughout this post, we explored the nine rules of Object Calisthenics and saw how each addresses issues still prevalent in modern applications: anemic domain models, classes with excessive responsibilities, tight coupling between objects and business rules scattered across layers.

It is worth noting that these rules were conceived as an exercise rather than a mandatory checklist. Their true value lies in identifying opportunities to apply them, especially in scenarios involving greater domain complexity, such as with entities and value objects.

For code involving infrastructure, DTOs, controllers and other boundary layers, a simpler approach is usually more appropriate.

A good first step toward putting these ideas into practice is to select a class from your current project that already seems problematic and try applying a few of the nine rules to it, the results might surprise you.

Object-oriented programming offers us excellent tools, and Object Calisthenics is a way to help us truly make the most of them.


Keep exploring ASP.NET Basics topics.


assis-zang-bio
About the Author

Assis Zang

Assis Zang is a software developer from Brazil, developing in the .NET platform since 2017. In his free time, he enjoys playing video games and reading good books. You can follow him at: LinkedIn and Github.

Related Posts

Comments

Comments are disabled in preview mode.