14  Designing Classes Well

Chapter 12 showed the mechanics of defining a class. This chapter is about judgment — the harder question of what a class should contain and why. Knowing how to write a class is a matter of an hour. Knowing how to design one well is a matter of years. This chapter cannot compress those years, but it can name the principles that guide good decisions and show what those principles look like in practice.

14.1 One Class, One Responsibility

The most reliable principle in class design is also the simplest to state: a class should have one responsibility. It should model one concept, manage one piece of state, and give callers one reason to use it.

A class that has one responsibility is easy to name. If you find yourself reaching for a name like UserManagerAndFormatter or OrderProcessorWithLogging, the class is doing too much. A class that is hard to name is usually a class that has not yet been designed — it is a collection of code that happens to share a file.

Consider the difference between these two designs for a student record:

nex> -- one class doing too much
nex> class Student
       create
         make(n, e: String) do
           name := n
           email := e
           scores := []
         end
       feature
         name: String
         email: String
         scores: Array[Integer]
         add_score(s: Integer) do
           scores.add(s)
         end
         average(): Real do
           result := 0.0
           across scores as s do
             result := result + s.to_real
           end
           result := result / scores.length.to_real
         end
         send_report() do
           -- connects to email server, formats HTML, sends message
         end
         export_to_csv(): String do
           -- formats a CSV row
         end
     end

This class manages student data, computes statistics, sends email, and exports to CSV. These are four different responsibilities. A change to the email sending logic requires touching the student class. A change to the CSV format requires touching the student class. Every part of the system that needs to change has found its way into one place.

The better design:

nex> class Student
       create
         make(n, e: String) do
           name := n
           email := e
           scores := []
         end
       feature
         name: String
         email: String
         scores: Array[Integer]
         add_score(s: Integer) do
           scores.add(s)
         end
         average(): Real do
           result := 0.0
           across scores as s do
             result := result + s.to_real
           end
           result := result / scores.length.to_real
         end
     end

Student manages student data and computes statistics intrinsic to a student. Sending email belongs to an email service. Exporting to CSV belongs to a report generator. Each class has one reason to exist.

14.2 What Belongs Inside a Class

A method belongs inside a class when it needs access to the class’s private fields to do its work, or when it represents an operation intrinsic to the concept the class models.

average belongs inside Student because it operates on scores, a private field. No code outside Student can access scores directly. More importantly, “a student’s average score” is an intrinsic property — it is something a student has, not something done to a student from outside.

A method does not belong inside a class when:

  • It does not need access to any private fields and could be written as a free function
  • It represents an operation from an external perspective — formatting for display, persisting to a database, sending over a network
  • It introduces a dependency on an external system that the class itself should not know about

The last point deserves emphasis. A Student class that sends email must depend on an email library. A Student class that exports CSV must know the CSV format. These dependencies belong to the systems that perform those operations, not to the data model they operate on. Keeping them out of Student means Student can be used, tested, and changed without any knowledge of how it is displayed, exported, or communicated.

14.3 Data and Behaviour Together

The insight that motivates object-oriented design is that data and the behaviour that naturally acts on it should live together. A BankAccount does not just hold a balance — it holds the balance and the rules for modifying it. Those rules are encoded in the methods. The data and its constraints are inseparable.

This distinguishes a well-designed class from a raw map. A map {"owner": "Alice", "balance": 1000.0} holds the same data as a BankAccount, but nothing prevents external code from setting the balance to a negative number. The class enforces its rules by controlling what operations are possible:

nex> class BankAccount
       create
         make(name: String, initial: Real) do
           owner := name
           balance := initial
           overdraft_limit := 0.0
         end
       feature
         owner: String
         balance: Real
         overdraft_limit: Real
         deposit(amount: Real) do
           balance := balance + amount
         end
         withdraw(amount: Real): Boolean do
           if balance - amount >= -overdraft_limit then
             balance := balance - amount
             result := true
           else
             result := false
           end
         end
         get_balance(): Real do
           result := balance
         end
     end

withdraw returns false when the withdrawal would exceed the limit rather than silently allowing an invalid state. The rule lives once, inside the class, and applies everywhere. No external code can bypass it.

14.4 Classes as Models

A well-designed class is a model of something: a real-world entity, a domain concept, or an abstraction. The fields are the properties that matter. The methods are the operations the model supports. Everything else is left out.

Consider modelling a playing card:

nex> class Card
       create
         make(r: Integer, s: String) do
           rank := r
           suit := s
         end
       feature
         rank: Integer
         suit: String
         name(): String do
           let rank_names := ["2","3","4","5","6","7","8","9","10","J","Q","K","A"]
           result := rank_names.get(rank - 2) + " of " + suit
         end
         beats(other: Card): Boolean do
           result := rank > other.rank
         end
     end

nex> let ace   := create Card.make(14, "Spades")
nex> let seven := create Card.make(7, "Hearts")
nex> ace.name
Ace of Spades

nex> ace.beats(seven)
true

Card does not include methods for shuffling (that belongs to Deck), dealing (that belongs to Game), or rendering to a screen (that belongs to a display layer). It models what a card is and what a card does in isolation.

14.5 The Difference Between Data Classes and Behaviour Classes

Not all classes have the same character. Some are primarily containers of data — their fields are the point, and methods exist to access or compute from those fields. Others are primarily engines of behaviour — their fields are implementation details that support the operations they expose.

Point, Card, and Student are data-heavy. Stack, Queue, and a word frequency counter are behaviour-heavy. Both kinds are legitimate. The mistake is confusing them.

A data class that accumulates behaviour becomes a god class — one class that knows and controls too much. A behaviour class that exposes its implementation details loses the encapsulation that made it worth defining.

The diagnostic question: what does a caller need to know to use this class correctly? For a data class, the answer is its fields and their meaning. For a behaviour class, the answer is its methods and their contracts. If the answer requires knowing about internal implementation details, the class has not been encapsulated well enough.

14.6 Naming Classes

A class name should be a noun or noun phrase that describes the concept being modelled. BankAccount, Student, Card, Stack — each names a thing.

Avoid names that describe what the class does rather than what it is: AccountManager, DataProcessor, Helper. These are symptoms of a class without a clear identity. A class named Helper is almost always a collection of unrelated functions that have not found their proper homes.

Avoid generic names that could describe anything: Manager, Handler, Controller, Utility. These tell a reader nothing about the class’s responsibility.

A good test: read the class name aloud and ask whether a domain expert — someone who knows the problem but not the code — would immediately understand what it represents. BankAccount passes. AccountDataManagerHelper does not.

14.7 A Worked Example: Redesigning a Class

Consider an initial draft of a Product class for an online store:

nex> class Product
       feature
         id: Integer
         name: String
         price: Real
         stock: Integer
         description: String
         discount_percent: Real
         category: String
         supplier_email: String
         last_ordered_date: String
         reorder_threshold: Integer
     end

Apply the single responsibility question: what is a Product? A product has an identity (id, name, category), a price, and a description. Stock management belongs to an Inventory concept. Supplier information belongs to a Supplier. Last ordered date is an event record, not a product attribute.

The redesigned model:

nex> class Product
       create
         make(i: Integer, n, cat, desc: String, price: Real) do
           id := i
           name := n
           category := cat
           description := desc
           base_price := price
         end
       feature
         id: Integer
         name: String
         category: String
         description: String
         base_price: Real
         discounted_price(percent: Real): Real do
           result := base_price * (1.0 - percent / 100.0)
         end
     end

nex> class StockRecord
       create
         make(pid, qty, threshold: Integer) do
           product_id := pid
           quantity := qty
           reorder_threshold := threshold
         end
       feature
         product_id: Integer
         quantity: Integer
         reorder_threshold: Integer
         needs_reorder(): Boolean do
           result := quantity <= reorder_threshold
         end
     end

Each class now has one responsibility. Product knows what a product is. StockRecord knows how much stock exists and when to reorder. The ten-field class was not wrong because it had ten fields — it was wrong because those fields belonged to different concepts.

14.8 Summary

  • A class should have one responsibility: one concept to model, one piece of state to manage, one reason to change
  • A method belongs inside a class when it operates on private fields or represents an intrinsic operation; not when it introduces external dependencies or could be a free function
  • Data and the behaviour that naturally governs it belong together; the class enforces invariants by controlling what operations are possible
  • Model only what is needed; speculative fields and methods make classes harder to understand and change
  • Data classes are centred on their fields; behaviour classes on their methods; god classes on nothing in particular
  • Class names should be nouns a domain expert would recognise; names describing what a class does rather than what it is are a warning sign
  • When a class has too many fields, ask which belong to different concepts and split accordingly

14.9 Exercises

1. The following class has more than one responsibility. Identify them and sketch a redesign that splits them into two or more classes:

class LibraryBook
  feature
    isbn: String
    title: String
    author: String
    is_checked_out: Boolean
    borrower_name: ?String
    borrower_email: ?String
    due_date: ?String
    late_fee_per_day: Real
end

2. Define a Money class with fields amount: Real and currency: String. Add methods add(other: Money): Money and exchange(rate: Real, target_currency: String): Money. What preconditions do these methods have? State them as comments.

3. A Deck class represents a standard 52-card deck. Using Card from Section 13.4, define Deck with a cards: Array[Card] field and methods make (constructor building all 52 cards), size(): Integer, draw(): Card, and is_empty(): Boolean. State the precondition for draw as a comment.

4. Review the BankAccount in Section 13.3. Is overdraft_limit something all bank accounts should have, or does it belong to a subtype? Sketch two classes — a basic Account with no overdraft, and an OverdraftAccount with a limit — without worrying about inheritance syntax. Which fields and methods does each have?

5.* The Stack from Chapter 12 works only with Integer values. Define a StringStack and a RealStack alongside it. What do you notice? What is the only thing that differs between them? This observation motivates generic types — a mechanism for writing a class once and using it with any element type — which we introduce in Chapter 15.