Chapter 13

Designing Classes Well

Now that we are comfortable with the basic mechanics of defining a class, let's think more 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. We cannot compress those years, but we can identify the principles that guide good decisions and figure out what those principles look like in practice.

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, 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 User_Manager_And_Formatter or Order_Processor_With_Logging, 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
     private feature
       name: String
       email: String
       scores: Array[Integer]
     feature
       add_score(s: Integer) do
         scores.add(s)
       end
       average(): Real do
         result := 0.0
         across scores as s do
           result := result + s
         end
         result := result / scores.length
       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 or an updation 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
     private feature
       name: String
       email: String
       scores: Array[Integer]
     feature
       add_score(s: Integer) do
         scores.add(s)
       end
       average(): Real do
         result := 0.0
         across scores as s do
           result := result + s
         end
         result := result / scores.length
       end
     end

Student manages student data and computes statistics intrinsic to a student, while sending email and exporting to CSV belong to services of their own. Each class has one reason to exist.

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, say, or persisting to a database
  • It introduces a dependency on an external system that the class itself should not know about

The last point deserves emphasis. There's no need for the Student class to depend on an email library or to 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 means Student can be used, tested, and changed without any knowledge of how it is displayed, exported, or communicated.

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 Bank_Account 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 Bank_Account, 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 Bank_Account
     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
       is_overdrawn(): Boolean do
         result := balance <= -overdraft_limit
       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.

Command-Query Separation

A command is a method that changes an object’s state. A query answers a question about the object. A well-designed method should usually do one or the other, not both.

Examples:

  • deposit(amount) is a command because it changes the balance
  • is_overdrawn() is a query because it reports information

Trouble begins when one routine tries to mix the two roles:

nex> class Counter
     private feature
       value: Integer
     feature
       next_value(): Integer do
         value := value + 1
         result := value
       end
     end

The name next_value sounds like a question, but calling it changes the object. A reader cannot tell from the name alone whether this is merely observing the counter or advancing it.

The clearer design separates the two responsibilities:

nex> class Counter
     private feature
       value: Integer
     feature
       increment() do
         value := value + 1
       end
       current(): Integer do
         result := value
       end
     end

Now the interface says exactly what happens. increment is a command and current is a query. A caller can ask for the current value without changing the object, and can change the object without pretending to ask a question.

This principle matters because mixed routines are harder to reason about:

  • A query that secretly changes state is surprising and error-prone
  • A command that also returns a value often invites callers to depend on two effects at once
  • Testing becomes less clear because one call both mutates the object and produces an answer

The principle is not absolute. Sometimes a combined routine is convenient, and sometimes performance considerations justify it. But convenience should be treated as an exception, not the default. When in doubt, prefer separate routines with separate purposes.

For class design, the practical rule is simple:

  • commands should be named as actions and should make state changes explicit
  • queries should answer questions and should not alter observable state

That discipline makes classes easier to read and easier to use correctly.

Classes as Models

A well-designed class is a model of a real-world entity or a domain concept. The attributes and methods of the class represent the properties and operations that matter for the domain, 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","Ace"]
         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) or rendering to a screen (that belongs to a display layer). It models what a card is and what a card does in isolation.

The Difference Between Data Classes and Behaviour Classes

Not all classes have the same character. Some are primarily containers of data — for them attributes are supreme, and methods exist to access or compute from those attributes. Others are primarily engines of behaviour — their attributes 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 attributes 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.

Naming Classes

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

Nex uses underscores to separate words in class names, such as Bank_Account or Stock_Record. This choice prioritizes readability by making the boundaries between words explicit. While many languages prefer BankAccount (CamelCase), the use of underscores ensures that each component of the name stands out clearly, even in long or technical terms. This aligns with the Nex philosophy: code should be as easy to read as a well-written sentence.

Avoid names that describe what the class does rather than what it is: Quote_Generator, Data_Processor, 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. Bank_Account passes. Account_Data_Manager_Helper does not.

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 and supplier information belong to concepts of their own (Inventory and Supplier), and the last-ordered date is an event record rather than 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 Stock_Record
     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. Stock_Record 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.

Summary

  • A class should have one responsibility: one concept to model, and 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
  • Prefer command-query separation: routines that change state should usually be distinct from routines that answer questions
  • Model only what is needed; speculative fields and methods make classes harder to understand and change
  • Data classes are centred on their attributes, behaviour classes on their methods
  • 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

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 Library_Book
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 attributes 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.5, 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 Bank_Account 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 Overdraft_Account with a limit — without worrying about inheritance syntax. Which attributes and methods does each have?

5.* The Stack from Chapter 12 works only with Integer values. Define a String_Stack and a Real_Stack 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.