Skip to content

Class Design Cheatsheet

A class is a promise: this is what the type means, and these are the only ways it can change. Everything here is a way of keeping that promise, or a way of accidentally breaking it.

Pairs with Module 2 — Classes and Objects and Module 4 — Encapsulation


The design checklist

Run this for every class you write. It takes two minutes and catches most design defects.

  • [ ] The responsibility fits in one sentence with no "and".
  • [ ] The invariant is stated, and there is one place that establishes it and one that preserves it.
  • [ ] Every data member is private.
  • [ ] Every member function that does not modify the object is const.
  • [ ] The interface is the smallest set of operations a caller actually needs — no getters "in case".
  • [ ] No getter returns a mutable reference to a member (see the leak table below).
  • [ ] Every public function either succeeds completely or changes nothing (where it can).
  • [ ] Nothing outside the class has to know the representation to use it.
  • [ ] You can name what the class owns, and it is expressed in the member types.

The interface: commands and queries

Kind Modifies the object? const? Example
Query No Yes int balance() const;
Command Yes No void deposit(int amount);
Factory / static No object yet n/a, and cannot be const static Account open(...);

Give the query a noun and the command a verb. It reads as the domain does: account.balance(), account.deposit(500).


Four ways a class leaks its representation

Each row is a real leak, and each has the same fix: return a value, or return const.

Leak Why it is a leak Fix
Public data member Anyone can set any state, including invalid Make it private, expose a command
T& items() { return items_; } Caller can insert anything, breaking the invariant Return by value, or expose queries
T& items() const { return items_; } Even from a const object, the caller mutates through the reference const T& items() const
setX for every member A setter per member is public data with extra steps Replace with operations that mean something
std::vector<std::string>& items() const { return items_; }   // does not compile, and should not
error: binding reference of type 'std::vector<std::__cxx11::basic_string<char> >&' to
       'const std::vector<std::__cxx11::basic_string<char> >' discards qualifiers

The compiler is doing your design review. std::__cxx11::basic_string<char> is GCC's internal name for std::string.


Invariants: state them, then make them impossible to break

Invariant flavour Where it is enforced
A single value's range (balance >= 0) In the setter-command, before the change
A relation between members (capacity >= size) In every command that touches either
A relation across a collection (no duplicate ids) In the class that owns the collection
A relation across two objects (no item lent twice) In exactly one of them, and the other asks
void Account::withdraw(int amount)
{
    if (amount <= 0)                     { throw std::invalid_argument("amount must be positive"); }
    if (amount > balance_)               { throw std::logic_error("insufficient balance"); }
    balance_ -= amount;                  // the only line that changes the state
}

The pattern: validate everything, then change exactly one thing. That is what makes the "leaves the object untouched on failure" guarantee easy to keep and easy to test.


const-correctness, quickly

Position Means
const T& parameter "I will not copy it and will not modify it" — the default for anything larger than a pointer
T f() const "Calling this does not change the object"
const T* Pointer to something you will not modify
T* const A pointer you will not reseat (rarely what you meant)

Mark it const and the compiler will tell you when you lied — that is the whole benefit. Doing this at the end, rather than as you go, is how people spend an evening adding const to fifty declarations.


Coupling: the two habits that matter most

Tell, do not ask. Instead of reading an object's state and deciding for it, ask the object to do the thing.

if (account.balance() >= 500) { account.withdraw(500); }   // asking, then acting — double validation
account.withdraw(500);                                     // telling: the rule lives in one place

Do not reach through. order.customer().address().city() couples you to three classes at once, and any of them changing breaks you. Ask the object you already have for what you need.


Class sizes, as a smell detector

Symptom What it usually means
More than ~7 public functions Probably two responsibilities
A name with "And", "Manager" or "Util" in it The responsibility was never found
A class with only getters and setters It is a struct pretending to be an object
Every function needs three parameters and this The data is in the wrong class
A member used by only one function It belongs in that function, or in another type

Checklist before you call a class finished

  • [ ] I can state its invariant out loud, in one sentence.
  • [ ] I tried to break it from main and could not.
  • [ ] It compiles with -Wall -Wextra -Werror and no warnings.
  • [ ] Copying it is either correct or forbidden — checked by Rule of Zero/Three/Five.
  • [ ] A caller can use it without reading its header comments for anything but what it does.

Related: C++ OOP Quick Reference · Rule of Zero, Three and Five · SOLID · Module 4 — Encapsulation