Skip to main content

Classes

Everything earlier in this course was about writing a good line, then a good function. None of that matters if the classes those functions live in are a mess. This page is about the next level up: how a class should be organized, how big it should be, and why "small" means something different for a class than it did for a function.

Class organization

A class reads top to bottom like a short document. Constants first, then static variables, then instance variables, then the public methods, with each public method followed by the private helpers it calls. A reader who starts at the top and moves down should never have to scroll back up to understand what they just read.

Keep variables and helper methods private by default - a public variable is rare enough that one showing up is worth a second look. Loosen that only when something concrete needs it, like a test in the same package reaching for a helper, and treat that as a last resort rather than a habit.

Classes should be small

Functions are measured in lines. Classes are measured in responsibilities, and that number is almost always smaller than people expect.

A class with five methods can still be too big if those five methods do unrelated things. A class with seventy methods can, in principle, be fine if all seventy serve one purpose (though that's rare in practice). The line count is a distraction; the question is always "how many reasons does this class have to change?"

A good gut check: write one sentence describing what the class does, and ban the words "and", "or", "but", and "if" from that sentence. If you can't avoid them, the class is doing more than one job. Class names are a related tell. Manager, Processor, Super, and similar vague nouns tend to show up on classes that accumulated responsibilities nobody assigned on purpose.

Single responsibility, applied to classes

A class should have one reason to change. Reading and converting a temperature and printing a report are three different reasons: a new sensor protocol, a new unit system, and a new report layout each touch a different concern, so bundling them means any one of those changes risks breaking the other two.

// One class reads sensors, converts units, AND formats a report.
// Three jobs, one file, three reasons to come back and edit it.
class WeatherStation is
field sensor: Sensor
field unit: string
 
method readTemperatureCelsius() is
return sensor.read()
 
method readTemperatureFahrenheit() is
celsius = sensor.read()
return celsius * 9 / 5 + 32
 
method printDailyReport() is
print("=== Daily Weather Report ===")
print("Temp: " + readTemperatureFahrenheit() + "F")
print("=============================")
// Reading, converting, and reporting are now three small classes.
// Each has one job and one reason to change.
class SensorReader is
field sensor: Sensor
 
method readCelsius() is
return sensor.read()
 
class TemperatureConverter is
method celsiusToFahrenheit(celsius) is
return celsius * 9 / 5 + 32
 
class DailyReport is
field reader: SensorReader
field converter: TemperatureConverter
 
method print() is
fahrenheit = converter.celsiusToFahrenheit(reader.readCelsius())
print("=== Daily Weather Report ===")
print("Temp: " + fahrenheit + "F")
print("=============================")
WeatherStation- sensor- unit+ readTemperatureCelsius()+ readTemperatureFahrenheit()+ printDailyReport()
BEFORE: one class, three reasons to change.
SensorReader- sensor+ readCelsius()TemperatureConverter+ celsiusToFahrenheit(c)DailyReport- reader- converter+ print()
uses
AFTER: each class answers to exactly one concern.

The natural worry is that more classes means more to navigate. It doesn't add up that way in practice - a system built from many small, clearly labeled classes has the same amount of logic as one built from a few sprawling ones, but you only ever have to open the drawer you actually need. A toolbox with fifty labeled compartments beats one drawer you dump everything into.

Cohesion

A class is cohesive when its methods actually use its instance variables. A class where every method touches every field is maximally cohesive; a stack with push, pop, and size built around one elements list and one topOfStack counter is a good example, since almost every method needs both fields.

Watch for the opposite: a handful of instance variables that only three of your fifteen methods ever touch. That's usually a second class trying to escape the first one. This shows up naturally when you break up a long function - if the piece you're extracting needs four local variables, promoting them to instance variables lets you extract a method with no parameters, but it also means the class picks up fields that only that one extracted method cares about. When you notice a subset of fields and methods only talking to each other, that subset wants to be its own class.

Organizing for change: the open/closed principle

The DailyReport class above will eventually need to support more than one output format. Left alone, that means reopening render() and adding another if branch every time a new format shows up, which puts a well-tested method at risk on every feature request.

// Every new report format edits the same method.
class DailyReport is
method render(format) is
if (format == "text")
return "Temp: " + fahrenheit + "F"
else if (format == "json")
return "{ \"tempF\": " + fahrenheit + " }"
// a third format means opening this method again
// The varying part becomes an interface. DailyReport never changes again.
interface ReportFormatter is
method render(fahrenheit)
 
class TextFormatter implements ReportFormatter is
method render(fahrenheit) is
return "Temp: " + fahrenheit + "F"
 
class JsonFormatter implements ReportFormatter is
method render(fahrenheit) is
return "{ \"tempF\": " + fahrenheit + " }"
 
class DailyReport is
field formatter: ReportFormatter
 
method render() is
return formatter.render(fahrenheit)

Pulling the varying part behind a ReportFormatter interface means a new format is a new class, and DailyReport itself never changes again. That's the open/closed principle: open to new behavior through extension, closed to edits in code that already works.

None of this is a reason to build the interface before a second format actually shows up - the moment to split is when you're about to reopen a class that already works, not out of fear that you might have to someday.

Isolating from change: depend on abstractions

The same idea applies to dependencies, not just behavior. If DailyReport constructs a concrete BluetoothSensor itself, every test of DailyReport now depends on real hardware being reachable, and swapping in a different sensor means editing the report class.

// DailyReport depends directly on one concrete sensor.
class DailyReport is
field sensor: BluetoothSensor
 
constructor DailyReport() is
this.sensor = new BluetoothSensor()
// DailyReport depends on an abstraction it defines itself.
interface TemperatureSensor is
method readCelsius()
 
class BluetoothSensor implements TemperatureSensor is
method readCelsius() is
// real hardware call
 
class FakeSensor implements TemperatureSensor is
method readCelsius() is
return 21 // fixed value, perfect for a test
 
class DailyReport is
field sensor: TemperatureSensor
 
constructor DailyReport(sensor: TemperatureSensor) is
this.sensor = sensor

DailyReport now depends on a TemperatureSensor abstraction it owns, and any concrete sensor - Bluetooth, a lab rig, or a fake that returns a fixed value for tests - can be handed in from outside. This is the dependency inversion principle: depend on the shape of the thing you need, not on one specific implementation of it. It's what makes the class testable without touching real hardware, and it's the same shape as the Shipping and Database examples from the SOLID principles page, just applied at the level of a whole class's dependencies rather than one method.