Steven's Knowledge

Classes

Class size, single responsibility, cohesion, and encapsulation

Classes

A function is the smallest unit of behavior; a class is the smallest unit of encapsulated behavior — a name attached to a bundle of state and the operations that read or change it. The properties that make functions easy to work with — clear name, single purpose, small surface — apply at the class level as well, with extra emphasis on what the class hides and what it exposes.

Why Classes Exist

A class earns its existence by:

  1. Naming a concept in the domain that is worth talking about (Invoice, Subscription, RetryPolicy).
  2. Owning state and the rules that apply to that state, so the rules cannot be bypassed.
  3. Hiding implementation so callers depend on a contract rather than on storage decisions.

Classes that do none of these — a bag of unrelated methods, a wrapper that exposes everything, a "Manager" that coordinates without owning anything — usually indicate a missing concept or a misplaced responsibility.

Size

Classes, like functions, should be small. The relevant measure is responsibilities, not line count.

The first test:

Describe the class in one sentence, without using "and," "or," or commas.

If you cannot, the class is doing more than one thing. Common offenders:

  • A class that exposes both what the data is and how the data is rendered.
  • A class that holds domain logic and also performs I/O.
  • A class that handles incoming requests, mutates state, and calls out to other systems.

The fix is to extract: separate the cluster of fields and methods that go together, give it a name, and let the original class delegate.

Single Responsibility Principle

A class should have one reason to change.

The phrasing matters. Two methods that operate on similar data may seem related, but if they answer to different stakeholders — a domain expert vs. a UI designer vs. an operations team — the methods will be modified for different reasons over time. Keeping them in one class produces Divergent Change: edits that should be local end up touching the same file from many directions.

In practice, the principle is applied by tracing the axes of change:

  • Format of the persisted record changes → repository changes.
  • Pricing rule changes → pricing-policy class changes.
  • Email template changes → notification class changes.

Each axis is a candidate boundary.

Cohesion

A class is cohesive when its methods all use most of its fields. Cohesion is what makes the class feel like a single concept rather than a folder.

Symptoms of weak cohesion:

  • A subset of methods uses one half of the fields; another subset uses the other half. The class wants to be two classes.
  • A field is used in only one method; it might be a local variable in disguise.
  • Methods take many arguments and ignore the instance state. They might be free functions.

When you find weak cohesion, split. The result is usually two cohesive classes whose responsibilities are easier to name than the original's.

Encapsulation

The point of a class is not "to group related code." It is to make state changes go through a controlled surface so the invariants of that state are enforceable.

Hide data; expose behavior

A class that exposes its fields directly is a Data Class — it provides storage but no enforcement. The rules about that data end up scattered across every caller.

// Storage with no contract
class Order {
  items = [];
  status = 'pending';
}
order.items.push(...);
order.status = 'paid'; // no validation, no events, no audit

// Behavior with enforced rules
class Order {
  #items = [];
  #status = 'pending';

  addItem(item) {
    if (this.#status !== 'pending') throw new IllegalStateError(...);
    this.#items.push(item);
  }

  markPaid(payment) {
    this.#assertConsistent(payment);
    this.#status = 'paid';
    this.#raise(new OrderPaid(...));
  }
}

The second form lets the class enforce addItem only allowed while pending in exactly one place.

The Data/Object dichotomy

Robert Martin's framing in Clean Code is useful: objects hide data and expose behavior; data structures expose data and have no behavior. A class should be unambiguously one or the other.

Hybrids — classes that expose getters/setters and contain logic — combine the disadvantages of both: callers can bypass the logic by reaching for the data, and the logic must defend against every caller having done so.

Plain data structures (DTOs, value objects, message types) are legitimate. The decision is to choose deliberately, not by accident.

Avoid leaky abstractions

When a class returns one of its internal collections, the recipient can mutate it. The class's invariants are now defended only by convention.

Defenses:

  • Return a read-only view (Object.freeze, Collections.unmodifiableList, persistent data structures).
  • Return a copy when the cost is acceptable.
  • Return an iterator that hides the underlying collection.

The same applies to mutable objects passed into a class: defensive copies prevent the caller from mutating state the class believes it owns.

Organization

Within a class, order the members so a reader can scan from general to specific:

  1. Public constants and types.
  2. Public constructor / factory.
  3. Public methods, ordered by the workflow a caller would follow.
  4. Private helpers, ordered to mirror the public methods that call them.
  5. Private state.

The goal is that a reader can stop at the public surface unless they choose to descend.

Inheritance and Composition

Inheritance is for "is-a"

A subclass is appropriate when the subclass is a kind of the superclass — every operation of the superclass is meaningful on the subclass, and substituting a subclass instance never breaks a caller (the Liskov Substitution Principle).

If the relationship is "uses-a" or "has-a," prefer composition. Holding a field of the relevant type leaves the design open to change; inheritance commits the subclass to every future change in the parent.

Inheritance has a tax

Inheritance creates the tightest coupling available between two pieces of code. Changes to the parent ripple to every subclass; changes in any subclass constrain the parent. The Pragmatic Programmer's phrase — the inheritance tax — captures the cost.

Modern designs lean heavily on composition (delegation, strategy objects, dependency injection) over inheritance. Inheritance remains useful for genuine type hierarchies and for languages where it is the idiomatic mechanism for polymorphism, but the default has shifted away from it.

Favor narrow interfaces over deep hierarchies

A small interface (one or two methods) implemented by many concrete types is easier to reason about than a deep hierarchy. Adding behavior is composition, not subclassing; adding a new variant is a new implementation, not a new layer.

Constructors

A constructor's job is to produce an object in a valid state. Two implications:

  • A constructor should refuse to construct an invalid instance — fail fast on invalid input rather than letting the caller observe a half-built object.
  • A constructor should not perform substantial work. Long-running construction (network calls, file I/O) makes objects expensive to create and difficult to test. Move the work behind a factory or a separate initialize() step that the caller invokes deliberately.

For complex construction, prefer factory functions:

  • They can return cached instances or subclasses.
  • They have a name that documents the variant being created (Order.fromCart(), Order.fromInvoice()).
  • They can validate or look up dependencies before allocating.

Dependencies

A class's dependencies determine how easy it is to test and how brittle it is in the face of change. Two practices help:

  • Inject dependencies, do not create them. A class that calls new Database() inside itself is bound to that class forever; a class that takes a Database parameter accepts any compatible implementation, including a fake for tests.
  • Depend on abstractions, not concretions. A class that takes a Logger interface accepts any implementation; one that takes a specific FileLogger does not.

The full theory belongs to Design Principles; the practice begins at the class level.

Pre-Commit Checklist

  • Can the class be described in one sentence without conjunctions?
  • Do the class's methods all use most of its fields?
  • Does the class hide data and expose behavior, or is it explicitly a data structure?
  • Are returned collections protected against external mutation?
  • Are dependencies injected, not constructed internally?
  • Does the constructor produce a valid instance and avoid heavy work?
  • Is inheritance used only for genuine "is-a" relationships?

On this page